Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[![npm](https://img.shields.io/npm/v/@elik56a/react-native-pickers)](https://www.npmjs.com/package/@elik56a/react-native-pickers) [![downloads](https://img.shields.io/npm/dw/@elik56a/react-native-pickers)](https://www.npmjs.com/package/@elik56a/react-native-pickers) [![bundle size](https://img.shields.io/bundlephobia/minzip/@elik56a/react-native-pickers)](https://bundlephobia.com/package/@elik56a/react-native-pickers) [![CI](https://github.com/elik56a/react-native-pickers/actions/workflows/ci.yml/badge.svg)](https://github.com/elik56a/react-native-pickers/actions/workflows/ci.yml) [![license](https://img.shields.io/npm/l/@elik56a/react-native-pickers)](LICENSE) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/elik56a/react-native-pickers/badge)](https://scorecard.dev/viewer/?uri=github.com/elik56a/react-native-pickers)

**Every picker a React Native app needs, in one package with zero native code.** Wheel, time, duration, date, month, week strip, date range, date & time, select, country, phone number, time zone, segmented control, emoji and color pickers, a slider, a star rating and an OTP code input, plus the bottom sheets and form fields to put them in. Native on iOS, Android and the web, and every detail is yours to customize.
**Every picker a React Native app needs, in one package with zero native code.** Wheel, time, duration, date, month, week strip, date range, date & time, select, country, phone number, time zone, segmented control, emoji and color pickers, a slider, a star rating and an OTP code input, plus bottom sheets, action sheets and the form fields to put pickers in. Native on iOS, Android and the web, and every detail is yours to customize.

<p align="center">
<img alt="React Native Pickers on an iPhone and an Android phone side by side: a date range picker with prices, wheel and clock-dial time pickers, a searchable select, an emoji picker, a phone number field, a color picker, a duration picker, and light and dark mode" src="https://raw.githubusercontent.com/elik56a/react-native-pickers/master/website/static/img/demo.gif" width="900">
Expand Down Expand Up @@ -110,6 +110,7 @@ Each field opens its picker in a bottom sheet on iOS and Android and in a dropdo
| [`Rating`](https://react-native-pickers.dev/docs/components/rating) | Star rating: tap or drag, half steps, a read-only score with any fraction, your own icon |
| [`EmojiPicker`](https://react-native-pickers.dev/docs/components/emoji-picker) | 1,923 emojis: categories, search, skin tones (long-press for one emoji's), recents, version filtering |
| [`BottomSheet`](https://react-native-pickers.dev/docs/components/bottom-sheet) | Animated sheet with drag-to-dismiss, for your own content |
| [`ActionSheet`](https://react-native-pickers.dev/docs/components/action-sheet) | A list of actions such as Share or Delete: iOS cards or Material rows, destructive and disabled actions |
| [Picker sheets](https://react-native-pickers.dev/docs/components/picker-sheets) | `DatePickerSheet`, `TimePickerSheet`, `DateTimePickerSheet`, `DurationPickerSheet`, `MonthPickerSheet`, `ColorPickerSheet`, `WheelPickerSheet`, `EmojiPickerSheet`: a picker in a sheet with Cancel / Done |
| [Picker fields](https://react-native-pickers.dev/docs/components/picker-fields) | `DatePickerField`, `TimePickerField`, `DateTimePickerField`, `DurationPickerField`, `MonthPickerField`, `ColorPickerField`, `WheelPickerField`, `EmojiPickerField`, `PickerField`: a form field that opens the picker |

Expand Down Expand Up @@ -464,13 +465,15 @@ A `SelectPicker` with every IANA time zone built in, each with its city, GMT off
| `recent` / `onRecentChange` | `string[]`: persist with your own storage | `[]` |
| `categoryBarPosition` | `'auto' \| 'top' \| 'bottom'`: `auto` is bottom on iOS and Android, top on the web | `'auto'` |

### Sheets, fields and BottomSheet
### Sheets, fields, BottomSheet and ActionSheet

| Component | Props |
|---|---|
| Picker sheets | `visible` `onClose` `value` `onConfirm` `title` `confirmLabel` `cancelLabel` `showCancel` `presentation` `sheetProps`, plus the picker's props |
| Picker fields | the sheet's props without `visible`, plus `label` `placeholder` `displayValue` `clearable` `onClear` `disabled` `presentation` `renderTrigger` `renderChevron` |
| `BottomSheet` | `visible` `onClose` `title` `headerLeft` `headerRight` `footer` `height` `enableSwipeToClose` `expandable` `presentation` |
| `BottomSheet` | `visible` `onClose` `title` `headerLeft` `headerRight` `footer` `height` `enableSwipeToClose` `expandable` `showHandle` `presentation` |
| `ActionSheet` | `visible` `onClose` `actions` (`{ label, onPress, description, icon, selected, destructive, disabled }`) `title` `message` `showCancel` `cancelLabel` `presentation`; `onPress` runs after the sheet closes |
| `useActionSheet()` | returns `{ show, hide, actionSheet }`: render `actionSheet` once and call `show({ title, actions })` from any handler, no provider |

### Utilities

Expand Down
2 changes: 1 addition & 1 deletion context7.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"url": "https://context7.com/elik56a/react-native-pickers",
"public_key": "pk_EfgECmIIv32CQlZpPe9GW",
"projectTitle": "React Native Pickers",
"description": "Wheel, time, duration, date, month, week strip, date-time, select, country, phone number, time zone, horizontal list, emoji and color pickers, a slider, a star rating, an OTP code input plus bottom sheets and form fields for React Native, Expo and React Native Web. Looks native on iOS, Android and the web, with zero native code (pure JavaScript).",
"description": "Wheel, time, duration, date, month, week strip, date-time, select, country, phone number, time zone, horizontal list, emoji and color pickers, a slider, a star rating, an OTP code input plus bottom sheets, action sheets and form fields for React Native, Expo and React Native Web. Looks native on iOS, Android and the web, with zero native code (pure JavaScript).",
"folders": ["website/docs"],
"rules": [
"Install @elik56a/react-native-pickers. It has zero native code (pure JavaScript), so it works in Expo Go without a rebuild.",
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@elik56a/react-native-pickers",
"version": "0.27.0",
"description": "Fully customizable React Native pickers with zero native code: wheel, time, duration, date (calendar, range, wheel), month, week strip, date-time, select (with server search), country (flags and dialing codes), phone number, time zone, segmented control, emoji and color, a slider (single or range), a star rating, an OTP code input, plus bottom sheets and form fields. Native iOS, Material and web designs. No native code, works with Expo and React Native Web.",
"description": "Fully customizable React Native pickers with zero native code: wheel, time, duration, date (calendar, range, wheel), month, week strip, date-time, select (with server search), country (flags and dialing codes), phone number, time zone, segmented control, emoji and color, a slider (single or range), a star rating, an OTP code input, plus bottom sheets, action sheets and form fields. Native iOS, Material and web designs. No native code, works with Expo and React Native Web.",
"license": "MIT",
"author": "Eliyahu Kriel <elik56a@gmail.com>",
"homepage": "https://react-native-pickers.dev/",
Expand Down Expand Up @@ -63,6 +63,8 @@
"expo",
"react-native-web",
"bottom-sheet",
"action-sheet",
"modal",
"datepicker",
"timepicker",
"datetime-picker",
Expand Down
90 changes: 90 additions & 0 deletions src/action/ActionSheet.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { useState } from 'react';

import { act, fireEvent, render, screen } from '@testing-library/react-native';

import { ActionSheet } from './ActionSheet';
import type { ActionSheetAction, ActionSheetProps } from './action.types';

const Harness = (props: Partial<ActionSheetProps>) => {
const [visible, setVisible] = useState(true);
return <ActionSheet visible={visible} onClose={() => setVisible(false)} actions={[]} {...props} />;
};

const finishAnimations = async () => {
await act(async () => {
jest.runAllTimers();
});
};

describe('ActionSheet', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});

it('runs an action after the sheet has closed', async () => {
const onPress = jest.fn();
await render(<Harness actions={[{ label: 'Share', onPress }]} />);
await fireEvent.press(screen.getByRole('button', { name: 'Share' }));
expect(onPress).not.toHaveBeenCalled();
await finishAnimations();
expect(onPress).toHaveBeenCalledTimes(1);
expect(screen.queryByRole('button', { name: 'Share' })).toBeNull();
});

it('closes on Cancel without running an action', async () => {
const onPress = jest.fn();
await render(<Harness design="ios" actions={[{ label: 'Share', onPress }]} />);
await fireEvent.press(screen.getByRole('button', { name: 'Cancel' }));
await finishAnimations();
expect(onPress).not.toHaveBeenCalled();
expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull();
});

it('shows Cancel by default in the iOS design only', async () => {
const { rerender } = await render(<ActionSheet visible onClose={jest.fn()} actions={[]} design="material" />);
expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull();
await rerender(<ActionSheet visible onClose={jest.fn()} actions={[]} design="material" showCancel cancelLabel="Dismiss" />);
expect(screen.getByRole('button', { name: 'Dismiss' })).toBeTruthy();
});

it('ignores a disabled action', async () => {
const onClose = jest.fn();
const action: ActionSheetAction = { label: 'Edit', disabled: true, onPress: jest.fn() };
await render(<ActionSheet visible onClose={onClose} actions={[action]} />);
await fireEvent.press(screen.getByRole('button', { name: 'Edit' }));
expect(onClose).not.toHaveBeenCalled();
});

it('shows the title as a header and the message', async () => {
await render(<ActionSheet visible onClose={jest.fn()} actions={[]} title="Delete photo?" message="It goes from all your devices." />);
expect(screen.getByRole('header', { name: 'Delete photo?' })).toBeTruthy();
expect(screen.getByText('It goes from all your devices.')).toBeTruthy();
});

it('marks the selected action and reads its description', async () => {
await render(
<ActionSheet
visible
onClose={jest.fn()}
actions={[
{ label: 'Newest', selected: true },
{ label: 'Price', description: 'Lowest first' },
]}
/>
);
expect(screen.getByRole('button', { name: 'Newest', selected: true })).toBeTruthy();
expect(screen.getByRole('button', { name: 'Price, Lowest first', selected: false })).toBeTruthy();
expect(screen.getByText('✓')).toBeTruthy();
});

it('still calls sheetProps.onClosed', async () => {
const onClosed = jest.fn();
await render(<Harness actions={[{ label: 'Share' }]} sheetProps={{ onClosed }} />);
await fireEvent.press(screen.getByRole('button', { name: 'Share' }));
await finishAnimations();
expect(onClosed).toHaveBeenCalledTimes(1);
});
});
82 changes: 82 additions & 0 deletions src/action/ActionSheet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { ScrollView, View } from 'react-native';

import { usePickerTheme } from '../core';
import { useDesignOverride } from '../core/theme/ThemeProvider';
import { BottomSheet } from '../sheet/BottomSheet';
import { DEFAULT_CANCEL_LABEL } from '../sheet/sheet.constants';
import { ACTION_SHEET_DESIGN } from './action.constants';
import { styles } from './action.styles';
import type { ActionSheetProps } from './action.types';
import { ActionSheetHeader } from './components/ActionSheetHeader';
import { ActionSheetRow } from './components/ActionSheetRow';
import { useActionPress } from './hooks/useActionPress';

/** A list of actions in a bottom sheet: iOS cards with a separate Cancel, or Material and web rows with icons. `useActionSheet` shows one from a call. */
export const ActionSheet = ({
visible,
onClose,
actions,
title,
message,
cancelLabel = DEFAULT_CANCEL_LABEL,
showCancel,
presentation,
sheetProps,
styles: slots,
theme: themeProp,
design,
testID,
}: ActionSheetProps) => {
const theme = usePickerTheme(useDesignOverride(themeProp, design));
const look = ACTION_SHEET_DESIGN[theme.design];
const { grouped } = look;
const { press, onClosed } = useActionPress(onClose, sheetProps?.onClosed);
const hasHeader = !!(title || message);
const group = grouped && [styles.group, { backgroundColor: theme.colors.sheet, borderRadius: theme.radius.lg }, slots?.group];

return (
<BottomSheet
expandable={false}
showHandle={!grouped}
presentation={presentation}
{...sheetProps}
styles={grouped ? { ...sheetProps?.styles, sheet: [styles.groupedSheet, sheetProps?.styles?.sheet] } : sheetProps?.styles}
visible={visible}
onClose={onClose}
onClosed={onClosed}
theme={themeProp}
design={design}
testID={testID}
>
<View style={group || styles.group}>
<ActionSheetHeader title={title} message={message} grouped={grouped} theme={theme} slots={slots} />
<ScrollView style={styles.list} bounces={false}>
{actions.map((action, i) => (
<ActionSheetRow
key={action.label}
action={action}
onPress={press}
grouped={grouped}
separated={grouped && (i > 0 || hasHeader)}
theme={theme}
slots={slots}
/>
))}
</ScrollView>
</View>
{(showCancel ?? look.showCancel) && (
<View style={[group, grouped && styles.cancelGroup]}>
<ActionSheetRow
action={{ label: cancelLabel, testID: testID ? `${testID}-cancel` : undefined }}
onPress={() => onClose()}
grouped={grouped}
cancel
separated={!grouped}
theme={theme}
slots={slots}
/>
</View>
)}
</BottomSheet>
);
};
14 changes: 14 additions & 0 deletions src/action/action.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { Design } from '../core/theme/theme.types';
import type { ActionSheetDesign } from './action.types';

/** iOS floats the actions in a rounded card with Cancel in its own card below; Material and the web list them on the sheet. */
export const ACTION_SHEET_DESIGN: Record<Design, ActionSheetDesign> = {
ios: { grouped: true, showCancel: true, rowHeight: 57 },
material: { grouped: false, showCancel: false, rowHeight: 56 },
web: { grouped: false, showCancel: false, rowHeight: 44 },
};

export const CHECK_MARK = '✓';

/** Space around the iOS cards and between the actions card and Cancel. */
export const GROUP_GAP = 8;
19 changes: 19 additions & 0 deletions src/action/action.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { StyleSheet } from 'react-native';

import { GROUP_GAP } from './action.constants';

export const styles = StyleSheet.create({
groupedSheet: { backgroundColor: 'transparent', paddingHorizontal: GROUP_GAP },
group: { flexShrink: 1, overflow: 'hidden' },
cancelGroup: { marginTop: GROUP_GAP },
list: { flexGrow: 0 },
header: { gap: 4 },
groupedHeader: { alignItems: 'center' },
centered: { textAlign: 'center' },
row: { flexDirection: 'row', alignItems: 'center' },
groupedRow: { justifyContent: 'center' },
separator: { borderTopWidth: StyleSheet.hairlineWidth },
text: { flex: 1 },
groupedText: { alignItems: 'center' },
groupedCheck: { position: 'absolute' },
});
95 changes: 95 additions & 0 deletions src/action/action.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import type React from 'react';

import type { PickerTheme, SlotStyles, ThemableProps } from '../core';
import type { BottomSheetProps, SheetPresentation } from '../sheet/sheet.types';

export interface ActionSheetAction {
label: string;
/** Runs after the sheet has closed, so it can open another sheet or modal */
onPress?: () => void;
/** A second line under the label */
description?: string;
/** Shown before the label, such as a 24px icon */
icon?: React.ReactNode;
/** Marks the current choice with a ✓, as in a Sort by sheet */
selected?: boolean;
/** Drawn in the theme's `danger` colour, for Delete and the like */
destructive?: boolean;
disabled?: boolean;
testID?: string;
}

export type ActionSheetSlot =
| 'group'
| 'header'
| 'title'
| 'message'
| 'action'
| 'actionLabel'
| 'actionDescription'
| 'check'
| 'cancel'
| 'cancelLabel';

export interface ActionSheetProps extends ThemableProps {
visible: boolean;
/** Called on Cancel, a backdrop tap, a swipe down or Back, and before any action runs */
onClose: () => void;
actions: ActionSheetAction[];
title?: string;
/** A line under the title, such as what a Delete removes */
message?: string;
/** Default: 'Cancel' */
cancelLabel?: string;
/** A Cancel button after the actions. Default: true in the iOS design; Material and web sheets close on a backdrop tap */
showCancel?: boolean;
/** Bottom sheet or centered modal. Default: 'auto' (modal on wide web windows) */
presentation?: SheetPresentation;
/** Passed through to the underlying BottomSheet */
sheetProps?: Partial<Omit<BottomSheetProps, 'visible' | 'onClose' | 'children'>>;
styles?: SlotStyles<ActionSheetSlot>;
testID?: string;
}

export interface ActionSheetDesign {
/** Rounded cards floating over the backdrop, Cancel in a card of its own (iOS), or rows on a plain sheet */
grouped: boolean;
showCancel: boolean;
rowHeight: number;
}

export interface ActionSheetHeaderProps {
title?: string;
message?: string;
grouped: boolean;
theme: PickerTheme;
slots?: SlotStyles<ActionSheetSlot>;
}

export interface ActionSheetRowProps {
action: ActionSheetAction;
onPress: (action: ActionSheetAction) => void;
grouped: boolean;
/** The Cancel button: bold in the iOS design */
cancel?: boolean;
/** A hairline above the row, between grouped actions */
separated: boolean;
theme: PickerTheme;
slots?: SlotStyles<ActionSheetSlot>;
}

/** `show()` takes the sheet's props without `visible`; `onClose` is optional. */
export type ActionSheetOptions = Omit<ActionSheetProps, 'visible' | 'onClose'> & { onClose?: () => void };

/** What `useActionSheet` returns: render `actionSheet` once, then call `show` from anywhere in the component. */
export interface ActionSheetController {
show: (options: ActionSheetOptions) => void;
hide: () => void;
actionSheet: React.ReactElement | null;
}

/** What `useActionPress` hands the sheet. Named so the build can emit the hook returning it. */
export interface ActionPress {
press: (action: ActionSheetAction) => void;
onClosed: () => void;
}
31 changes: 31 additions & 0 deletions src/action/components/ActionSheetHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Text, View } from 'react-native';

import { textStyle } from '../../core';
import { styles } from '../action.styles';
import type { ActionSheetHeaderProps } from '../action.types';

export const ActionSheetHeader = ({ title, message, grouped, theme, slots }: ActionSheetHeaderProps) => {
if (!title && !message) return null;
const c = theme.colors;
const align = grouped && styles.centered;
return (
<View
style={[
styles.header,
grouped && styles.groupedHeader,
{ paddingHorizontal: theme.spacing.lg, paddingVertical: grouped ? theme.spacing.md : theme.spacing.sm },
slots?.header,
]}
>
{title ? (
<Text
style={[textStyle(theme, grouped ? 'sm' : 'md', 'bold'), { color: grouped ? c.textSecondary : c.text }, align, slots?.title]}
accessibilityRole="header"
>
{title}
</Text>
) : null}
{message ? <Text style={[textStyle(theme, 'sm'), { color: c.textSecondary }, align, slots?.message]}>{message}</Text> : null}
</View>
);
};
Loading