From c45dd404eedcae03652a6246ba8e244e67932111 Mon Sep 17 00:00:00 2001 From: eliyahu-kriel <44751346+elik56a@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:33:26 +0200 Subject: [PATCH] feat(action): add ActionSheet and useActionSheet, plus migration guides for sheets and modals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ActionSheet lists actions in a BottomSheet: the iOS design floats them in a rounded card with Cancel in its own card, Material and the web show rows with icons and no Cancel by default. Actions can be destructive (the theme's new danger colour), disabled, selected (a ✓ for the current choice) and carry a description line. A tap closes the sheet and the action runs once it has closed, so it can open another modal. useActionSheet shows one from a call, like showActionSheetWithOptions, with no provider: render the returned actionSheet once and call show(). BottomSheet gains showHandle. New migration guides cover @gorhom/bottom-sheet, react-native-modal, react-native-actions-sheet and @expo/react-native-action-sheet. The showcase gains "Photo albums". Co-Authored-By: Claude Opus 5.5 --- README.md | 9 +- context7.json | 2 +- package.json | 4 +- src/action/ActionSheet.test.tsx | 90 ++++++ src/action/ActionSheet.tsx | 82 ++++++ src/action/action.constants.ts | 14 + src/action/action.styles.ts | 19 ++ src/action/action.types.ts | 95 +++++++ src/action/components/ActionSheetHeader.tsx | 31 ++ src/action/components/ActionSheetRow.tsx | 66 +++++ src/action/hooks/useActionPress.ts | 20 ++ src/action/hooks/useActionSheet.test.tsx | 42 +++ src/action/hooks/useActionSheet.tsx | 25 ++ src/action/index.ts | 3 + src/core/theme/theme.constants.ts | 2 + src/core/theme/theme.types.ts | 2 + src/index.ts | 1 + src/sheet/BottomSheet.tsx | 3 +- src/sheet/sheet.types.ts | 2 + website/docs/api/reference.mdx | 2 + website/docs/components/action-sheet.mdx | 269 ++++++++++++++++++ website/docs/components/bottom-sheet.mdx | 3 + website/docs/guides/designs.mdx | 1 + website/docs/guides/styling.mdx | 1 + website/docs/guides/theming.mdx | 1 + website/docs/guides/typescript.mdx | 1 + website/docs/intro.mdx | 3 +- website/docs/resources/ai.mdx | 5 +- .../resources/migration/actions-sheet.mdx | 44 +++ .../resources/migration/expo-action-sheet.mdx | 63 ++++ .../migration/gorhom-bottom-sheet.mdx | 64 +++++ website/docs/resources/migration/index.mdx | 6 +- .../migration/react-native-modal.mdx | 50 ++++ website/docusaurus.config.ts | 6 +- website/sidebars.ts | 4 + website/src/catalog/catalog.constants.ts | 2 + website/src/catalog/playgrounds.tsx | 15 + website/src/home/components/Hero.tsx | 4 +- website/src/home/home.constants.ts | 3 +- website/src/showcase/ShowcaseApp.tsx | 2 + .../src/showcase/components/AlbumsScreen.tsx | 73 +++++ website/src/showcase/showcase.constants.ts | 24 +- website/src/showcase/showcase.styles.ts | 4 + website/src/showcase/showcase.types.ts | 10 +- website/src/showcase/showcase.utils.test.ts | 21 +- website/src/showcase/showcase.utils.ts | 11 +- 46 files changed, 1183 insertions(+), 21 deletions(-) create mode 100644 src/action/ActionSheet.test.tsx create mode 100644 src/action/ActionSheet.tsx create mode 100644 src/action/action.constants.ts create mode 100644 src/action/action.styles.ts create mode 100644 src/action/action.types.ts create mode 100644 src/action/components/ActionSheetHeader.tsx create mode 100644 src/action/components/ActionSheetRow.tsx create mode 100644 src/action/hooks/useActionPress.ts create mode 100644 src/action/hooks/useActionSheet.test.tsx create mode 100644 src/action/hooks/useActionSheet.tsx create mode 100644 src/action/index.ts create mode 100644 website/docs/components/action-sheet.mdx create mode 100644 website/docs/resources/migration/actions-sheet.mdx create mode 100644 website/docs/resources/migration/expo-action-sheet.mdx create mode 100644 website/docs/resources/migration/gorhom-bottom-sheet.mdx create mode 100644 website/docs/resources/migration/react-native-modal.mdx create mode 100644 website/src/showcase/components/AlbumsScreen.tsx diff --git a/README.md b/README.md index 8dfc168..6803739 100644 --- a/README.md +++ b/README.md @@ -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.

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 @@ -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 | @@ -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 diff --git a/context7.json b/context7.json index 0a88334..3e2582e 100644 --- a/context7.json +++ b/context7.json @@ -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.", diff --git a/package.json b/package.json index 3d906bf..16063d4 100644 --- a/package.json +++ b/package.json @@ -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 ", "homepage": "https://react-native-pickers.dev/", @@ -63,6 +63,8 @@ "expo", "react-native-web", "bottom-sheet", + "action-sheet", + "modal", "datepicker", "timepicker", "datetime-picker", diff --git a/src/action/ActionSheet.test.tsx b/src/action/ActionSheet.test.tsx new file mode 100644 index 0000000..ea14427 --- /dev/null +++ b/src/action/ActionSheet.test.tsx @@ -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) => { + const [visible, setVisible] = useState(true); + return 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(); + 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(); + 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(); + expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull(); + await rerender(); + 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(); + 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(); + 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( + + ); + 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(); + await fireEvent.press(screen.getByRole('button', { name: 'Share' })); + await finishAnimations(); + expect(onClosed).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/action/ActionSheet.tsx b/src/action/ActionSheet.tsx new file mode 100644 index 0000000..10f2950 --- /dev/null +++ b/src/action/ActionSheet.tsx @@ -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 ( + + + + + {actions.map((action, i) => ( + 0 || hasHeader)} + theme={theme} + slots={slots} + /> + ))} + + + {(showCancel ?? look.showCancel) && ( + + onClose()} + grouped={grouped} + cancel + separated={!grouped} + theme={theme} + slots={slots} + /> + + )} + + ); +}; diff --git a/src/action/action.constants.ts b/src/action/action.constants.ts new file mode 100644 index 0000000..22749c1 --- /dev/null +++ b/src/action/action.constants.ts @@ -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 = { + 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; diff --git a/src/action/action.styles.ts b/src/action/action.styles.ts new file mode 100644 index 0000000..e97d647 --- /dev/null +++ b/src/action/action.styles.ts @@ -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' }, +}); diff --git a/src/action/action.types.ts b/src/action/action.types.ts new file mode 100644 index 0000000..909b594 --- /dev/null +++ b/src/action/action.types.ts @@ -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>; + styles?: SlotStyles; + 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; +} + +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; +} + +/** `show()` takes the sheet's props without `visible`; `onClose` is optional. */ +export type ActionSheetOptions = Omit & { 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; +} diff --git a/src/action/components/ActionSheetHeader.tsx b/src/action/components/ActionSheetHeader.tsx new file mode 100644 index 0000000..cbebf27 --- /dev/null +++ b/src/action/components/ActionSheetHeader.tsx @@ -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 ( + + {title ? ( + + {title} + + ) : null} + {message ? {message} : null} + + ); +}; diff --git a/src/action/components/ActionSheetRow.tsx b/src/action/components/ActionSheetRow.tsx new file mode 100644 index 0000000..771e2e8 --- /dev/null +++ b/src/action/components/ActionSheetRow.tsx @@ -0,0 +1,66 @@ +import { Text, View } from 'react-native'; + +import { textStyle } from '../../core'; +import { Touchable } from '../../core/components/Touchable'; +import { ACTION_SHEET_DESIGN, CHECK_MARK } from '../action.constants'; +import { styles } from '../action.styles'; +import type { ActionSheetRowProps } from '../action.types'; + +export const ActionSheetRow = ({ action, onPress, grouped, cancel = false, separated, theme, slots }: ActionSheetRowProps) => { + const c = theme.colors; + const color = action.disabled ? c.textDisabled : action.destructive ? c.danger : grouped ? c.primary : c.text; + return ( + onPress(action)} + disabled={action.disabled} + accessibilityRole="button" + accessibilityLabel={action.description ? `${action.label}, ${action.description}` : action.label} + accessibilityState={{ disabled: action.disabled, selected: action.selected }} + testID={action.testID} + style={[ + styles.row, + grouped && styles.groupedRow, + { + minHeight: ACTION_SHEET_DESIGN[theme.design].rowHeight, + paddingHorizontal: theme.spacing.lg, + paddingVertical: theme.spacing.sm, + gap: theme.spacing.md, + }, + separated && [styles.separator, { borderColor: c.border }], + slots?.[cancel ? 'cancel' : 'action'], + ]} + > + {action.icon} + + + {action.label} + + {action.description ? ( + + {action.description} + + ) : null} + + {action.selected ? ( + + {CHECK_MARK} + + ) : null} + + ); +}; diff --git a/src/action/hooks/useActionPress.ts b/src/action/hooks/useActionPress.ts new file mode 100644 index 0000000..93bf82b --- /dev/null +++ b/src/action/hooks/useActionPress.ts @@ -0,0 +1,20 @@ +import { useRef } from 'react'; + +import type { ActionPress, ActionSheetAction } from '../action.types'; + +/** Closes the sheet on a press and runs the action once it's gone, so the action can present another modal (iOS shows one at a time). */ +export const useActionPress = (onClose: () => void, onClosed?: () => void): ActionPress => { + const pending = useRef(null); + return { + press: (action) => { + pending.current = action; + onClose(); + }, + onClosed: () => { + const action = pending.current; + pending.current = null; + onClosed?.(); + action?.onPress?.(); + }, + }; +}; diff --git a/src/action/hooks/useActionSheet.test.tsx b/src/action/hooks/useActionSheet.test.tsx new file mode 100644 index 0000000..e249e88 --- /dev/null +++ b/src/action/hooks/useActionSheet.test.tsx @@ -0,0 +1,42 @@ +import { Pressable, Text } from 'react-native'; + +import { act, fireEvent, render, screen } from '@testing-library/react-native'; + +import type { ActionSheetOptions } from '../action.types'; +import { useActionSheet } from './useActionSheet'; + +const Screen = ({ options }: { options: ActionSheetOptions }) => { + const { show, actionSheet } = useActionSheet(); + return ( + <> + show(options)}> + Open + + {actionSheet} + + ); +}; + +describe('useActionSheet', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + afterEach(() => { + jest.useRealTimers(); + }); + + it('shows the sheet on show(), and runs the action after it closes', async () => { + const onPress = jest.fn(); + const onClose = jest.fn(); + await render(); + expect(screen.queryByRole('button', { name: 'Share' })).toBeNull(); + await fireEvent.press(screen.getByText('Open')); + await fireEvent.press(screen.getByRole('button', { name: 'Share' })); + expect(onClose).toHaveBeenCalledTimes(1); + await act(async () => { + jest.runAllTimers(); + }); + expect(onPress).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('button', { name: 'Share' })).toBeNull(); + }); +}); diff --git a/src/action/hooks/useActionSheet.tsx b/src/action/hooks/useActionSheet.tsx new file mode 100644 index 0000000..af4e4bd --- /dev/null +++ b/src/action/hooks/useActionSheet.tsx @@ -0,0 +1,25 @@ +import { useCallback, useState } from 'react'; + +import { ActionSheet } from '../ActionSheet'; +import type { ActionSheetController, ActionSheetOptions } from '../action.types'; + +/** Shows an action sheet from a call, like `showActionSheetWithOptions`: render the returned `actionSheet` once. */ +export const useActionSheet = (): ActionSheetController => { + const [options, setOptions] = useState(null); + const [visible, setVisible] = useState(false); + const show = useCallback((next: ActionSheetOptions) => { + setOptions(next); + setVisible(true); + }, []); + const hide = useCallback(() => setVisible(false), []); + const onClose = () => { + hide(); + options?.onClose?.(); + }; + + return { + show, + hide, + actionSheet: options ? : null, + }; +}; diff --git a/src/action/index.ts b/src/action/index.ts new file mode 100644 index 0000000..a5cdb5d --- /dev/null +++ b/src/action/index.ts @@ -0,0 +1,3 @@ +export { ActionSheet } from './ActionSheet'; +export type { ActionSheetAction, ActionSheetController, ActionSheetOptions, ActionSheetProps, ActionSheetSlot } from './action.types'; +export { useActionSheet } from './hooks/useActionSheet'; diff --git a/src/core/theme/theme.constants.ts b/src/core/theme/theme.constants.ts index 5377d92..64d430f 100644 --- a/src/core/theme/theme.constants.ts +++ b/src/core/theme/theme.constants.ts @@ -43,6 +43,7 @@ export const lightTheme: PickerTheme = { handle: '#D8D3C9', hover: 'rgba(23,21,15,0.05)', ripple: 'rgba(23,21,15,0.18)', + danger: '#D92D20', }, ...SHARED_TOKENS, }; @@ -65,6 +66,7 @@ export const darkTheme: PickerTheme = { handle: '#3D3930', hover: 'rgba(255,255,255,0.06)', ripple: 'rgba(255,255,255,0.2)', + danger: '#FF6B5F', }, ...SHARED_TOKENS, }; diff --git a/src/core/theme/theme.types.ts b/src/core/theme/theme.types.ts index 88a4608..7c82271 100644 --- a/src/core/theme/theme.types.ts +++ b/src/core/theme/theme.types.ts @@ -31,6 +31,8 @@ export interface PickerTheme { hover: string; /** The ripple that spreads from the touch point on press. 'transparent' turns it off */ ripple: string; + /** Destructive actions, such as Delete in an action sheet */ + danger: string; }; radius: { sm: number; md: number; lg: number; full: number; sheet: number }; spacing: { xs: number; sm: number; md: number; lg: number; xl: number }; diff --git a/src/index.ts b/src/index.ts index 87f69d2..64ed54e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +export * from './action'; export * from './calendar'; export * from './color'; export * from './core'; diff --git a/src/sheet/BottomSheet.tsx b/src/sheet/BottomSheet.tsx index 6e32740..3606f00 100644 --- a/src/sheet/BottomSheet.tsx +++ b/src/sheet/BottomSheet.tsx @@ -25,6 +25,7 @@ export const BottomSheet = ({ draggable: draggableProp, expandable = true, expandedHeight, + showHandle = true, closeOnBackdropPress = true, maxHeightRatio = DEFAULT_MAX_HEIGHT_RATIO, height, @@ -103,7 +104,7 @@ export const BottomSheet = ({ > {/* collapsable={false}: not flattened away, so a touch anywhere on the strip reaches the drag handlers */} - {!dialog && ( + {!dialog && showHandle && ( diff --git a/src/sheet/sheet.types.ts b/src/sheet/sheet.types.ts index 114f1d4..cb63366 100644 --- a/src/sheet/sheet.types.ts +++ b/src/sheet/sheet.types.ts @@ -45,6 +45,8 @@ export interface BottomSheetProps extends ThemableProps { expandable?: boolean; /** Expanded height (px, or a fraction of the window when <= 1). Default: the window below the status bar */ expandedHeight?: number; + /** The grab bar at the top of a bottom sheet. Default: true */ + showHandle?: boolean; /** Tap the backdrop to dismiss. Default: true */ closeOnBackdropPress?: boolean; /** Max sheet height as a fraction of the window. Default: 0.9 */ diff --git a/website/docs/api/reference.mdx b/website/docs/api/reference.mdx index aa6933d..9f39ea0 100644 --- a/website/docs/api/reference.mdx +++ b/website/docs/api/reference.mdx @@ -33,6 +33,7 @@ Every export from `@elik56a/react-native-pickers`, grouped. Each name links to i | [`OtpField`](../components/otp-field) | One-time code in a box per character, with SMS autofill and paste | | [`Rating`](../components/rating) | Stars to tap or drag, whole or half steps, or a read-only score | | [`BottomSheet`](../components/bottom-sheet) | Animated sheet | +| [`ActionSheet`](../components/action-sheet) | A list of actions in a sheet: iOS cards or Material rows, destructive, disabled and selected actions | ## Sheets @@ -48,6 +49,7 @@ Every export from `@elik56a/react-native-pickers`, grouped. Each name links to i | [`EmojiPickerSheet`](../components/picker-sheets#emojipickersheet) | EmojiPicker in a sheet | | [`ColorPickerSheet`](../components/color-picker#in-a-sheet) | ColorPicker in a sheet | | [`useDraft(value, visible, { resetKey?, commit? })`](../components/picker-sheets#usedraft) | Draft state that resets on open; commits each change inline | +| [`useActionSheet()`](../components/action-sheet#show-it-from-a-call) | `{ show, hide, actionSheet }`: show an ActionSheet from a call, no provider | ## Fields diff --git a/website/docs/components/action-sheet.mdx b/website/docs/components/action-sheet.mdx new file mode 100644 index 0000000..84d9add --- /dev/null +++ b/website/docs/components/action-sheet.mdx @@ -0,0 +1,269 @@ +--- +title: React Native Action Sheet +description: "React Native action sheet with iOS-style grouped buttons and a separate Cancel, or Material and web rows with icons and descriptions. Destructive, disabled and selected actions, a useActionSheet hook to show it from a call, and the action runs after the sheet closes. Zero native code." +--- + +import PropPlayground from '@site/src/components/PropPlayground'; +import { PLAYGROUNDS } from '@site/src/catalog/playgrounds'; + +# ActionSheet + +`ActionSheet` asks "what do you want to do with this?": a short list of actions in a [`BottomSheet`](./bottom-sheet), such as Share, Edit or Delete. The iOS design floats the actions in a rounded card with Cancel in a card of its own; Material and the web list them as rows on the sheet, with icons if you pass them. + +```tsx +import { ActionSheet } from '@elik56a/react-native-pickers'; +``` + +```tsx live +import { useState } from 'react'; +import { Pressable, Text } from 'react-native'; +import { ActionSheet } from '@elik56a/react-native-pickers'; + +export default function Example() { + const [open, setOpen] = useState(false); + const [last, setLast] = useState('Nothing yet'); + return ( + <> + setOpen(true)} + style={{ backgroundColor: '#C8460D', padding: 14, borderRadius: 12 }} + > + + Photo options + + + Last action: {last} + setOpen(false)} + title="Profile photo" + actions={[ + { label: 'Take photo', onPress: () => setLast('Take photo') }, + { label: 'Choose from library', onPress: () => setLast('Choose from library') }, + { label: 'Remove photo', destructive: true, onPress: () => setLast('Remove photo') }, + ]} + /> + + ); +} +``` + +## Try the props + + + +## Actions + +Each action is `{ label, onPress, description, icon, selected, destructive, disabled, testID }`: + +- `description` adds a second line under the label. +- `icon` is any node shown before the label, such as a 24 px icon from the icon set you already use. +- `selected` marks the current choice with a ✓. +- `destructive` draws the label in the theme's `danger` colour, for Delete, Remove or Sign out. +- `disabled` greys the action out and ignores taps. + +A tap closes the sheet through `onClose`, and the action's `onPress` runs once the sheet has finished closing. That way an action can open another sheet, a modal or the image picker: iOS shows only one modal at a time. + +### Icons and descriptions + +Icons suit the Material and web rows, where they line up at the start. Switch the example to Android or Web to see them as rows. + +```tsx live +import { useState } from 'react'; +import { Pressable, Text } from 'react-native'; +import { ActionSheet } from '@elik56a/react-native-pickers'; + +const icon = (emoji) => {emoji}; + +export default function Example() { + const [open, setOpen] = useState(false); + return ( + <> + setOpen(true)} + style={{ backgroundColor: '#C8460D', padding: 14, borderRadius: 12 }} + > + + Share + + + setOpen(false)} + title="Share “Summer trip”" + actions={[ + { label: 'Copy link', description: 'Anyone with the link can view', icon: icon('🔗') }, + { label: 'Send in a message', icon: icon('💬') }, + { label: 'Invite people', description: 'They can add photos too', icon: icon('👥') }, + { label: 'Download all', icon: icon('⬇️'), disabled: true }, + ]} + /> + + ); +} +``` + +### A choice with a ✓ + +For a short choice that applies right away, such as a sort order or video quality, mark the current one with `selected`. + +```tsx live +import { useState } from 'react'; +import { Pressable, Text } from 'react-native'; +import { ActionSheet } from '@elik56a/react-native-pickers'; + +const SORTS = ['Newest', 'Oldest', 'Most liked', 'Name']; + +export default function Example() { + const [open, setOpen] = useState(false); + const [sort, setSort] = useState('Newest'); + return ( + <> + setOpen(true)} + style={{ backgroundColor: '#C8460D', padding: 14, borderRadius: 12 }} + > + + Sort by: {sort} + + + setOpen(false)} + title="Sort by" + actions={SORTS.map((label) => ({ + label, + selected: label === sort, + onPress: () => setSort(label), + }))} + /> + + ); +} +``` + +For a longer list, or one with search, use [`SelectPicker`](./select-picker). + +## Confirm a delete + +`title` names what the actions apply to; `message` adds a line under it, such as what a Delete removes. With one destructive action, the sheet is a confirmation. + +```tsx live +import { useState } from 'react'; +import { Pressable, Text } from 'react-native'; +import { ActionSheet } from '@elik56a/react-native-pickers'; + +export default function Example() { + const [open, setOpen] = useState(false); + const [notes, setNotes] = useState(3); + return ( + <> + setOpen(true)} + disabled={notes === 0} + style={{ backgroundColor: '#C8460D', padding: 14, borderRadius: 12, opacity: notes ? 1 : 0.4 }} + > + + Delete a note ({notes} left) + + + setOpen(false)} + title="Delete this note?" + message="It's removed from all your devices." + showCancel + actions={[{ label: 'Delete note', destructive: true, onPress: () => setNotes((n) => n - 1) }]} + /> + + ); +} +``` + +## Show it from a call + +`useActionSheet` shows a sheet without a `visible` state of your own, as `showActionSheetWithOptions` does in `@expo/react-native-action-sheet`. Render the `actionSheet` it returns once, then call `show` with the sheet's props from any handler. It needs no provider. + +```tsx live +import { useState } from 'react'; +import { Pressable, Text, View } from 'react-native'; +import { useActionSheet } from '@elik56a/react-native-pickers'; + +const FILES = ['Invoice.pdf', 'Photo.jpg', 'Notes.txt']; + +export default function Example() { + const { show, actionSheet } = useActionSheet(); + const [last, setLast] = useState('Nothing yet'); + const openMenu = (file) => + show({ + title: file, + actions: [ + { label: 'Rename', onPress: () => setLast(`Rename ${file}`) }, + { label: 'Delete', destructive: true, onPress: () => setLast(`Delete ${file}`) }, + ], + }); + return ( + + {FILES.map((file) => ( + openMenu(file)} style={{ padding: 14, borderRadius: 12, backgroundColor: '#F4F2EE' }}> + {file} ⋯ + + ))} + Last action: {last} + {actionSheet} + + ); +} +``` + +`show` takes every `ActionSheet` prop except `visible`, and `onClose` is optional. `hide()` closes the sheet from code. + +## Cancel + +The iOS design ends with a Cancel button in its own card, as iOS does. Material and web sheets don't: they close on a backdrop tap, a swipe down or Back. `showCancel` turns it on or off for any design, and `cancelLabel` renames it. Cancel, the backdrop, a swipe and Back all call `onClose` without running an action. + +## Bottom sheet or modal + +`presentation` is the same as on [`BottomSheet`](./bottom-sheet#bottom-sheet-or-modal): `'auto'` (default) is a centered modal on wide web windows and a bottom sheet everywhere else. Anything else the sheet takes, such as `bottomInset` or `onClosed`, goes in `sheetProps`. + +## Props + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `visible` | `boolean` | **required** | Show or hide (animated). | +| `onClose` | `() => void` | **required** | Set `visible` to `false` here. Called on any action, Cancel, a backdrop tap, a swipe down and Back. | +| `actions` | `ActionSheetAction[]` | **required** | The actions, top to bottom. | +| `title` | `string` | — | What the actions apply to. | +| `message` | `string` | — | A line under the title. | +| `showCancel` | `boolean` | `true` iOS / `false` Material and web | A Cancel button after the actions. | +| `cancelLabel` | `string` | `'Cancel'` | | +| `presentation` | `'auto' \| 'bottomSheet' \| 'modal'` | `'auto'` | Bottom sheet or centered modal. | +| `sheetProps` | `Partial` | — | Passed to the [`BottomSheet`](./bottom-sheet#props). | +| `theme` / `design` | | | See [Theming](../guides/theming) and [Designs](../guides/designs). | +| `styles` | `{ group, header, title, message, action, actionLabel, actionDescription, check, cancel, cancelLabel }` | — | [Slot styles](../guides/styling): the iOS cards, the title block, each action row, its label, description and ✓, and Cancel. | +| `testID` | `string` | — | On the sheet; Cancel gets `${testID}-cancel`. | + +```ts +interface ActionSheetAction { + label: string; + onPress?: () => void; // runs after the sheet has closed + description?: string; // a second line under the label + icon?: ReactNode; + selected?: boolean; // a ✓ for the current choice + destructive?: boolean; // theme danger colour + disabled?: boolean; + testID?: string; +} + +const { show, hide, actionSheet } = useActionSheet(); +show(options: Omit & { onClose?: () => void }); +``` + +## Accessibility + +Each action is a button named by its label and description, marked disabled or selected, and the title is a header. On the web, arrow keys move between actions and Escape closes the sheet. + +## Related + +- [BottomSheet](./bottom-sheet): the sheet underneath, for your own content +- [Migrate from @expo/react-native-action-sheet](../resources/migration/expo-action-sheet) or [react-native-actions-sheet](../resources/migration/actions-sheet) diff --git a/website/docs/components/bottom-sheet.mdx b/website/docs/components/bottom-sheet.mdx index 3e829e0..e17d365 100644 --- a/website/docs/components/bottom-sheet.mdx +++ b/website/docs/components/bottom-sheet.mdx @@ -235,6 +235,7 @@ On iOS the sheet is wrapped in a `KeyboardAvoidingView`, so text inputs inside s | `expandable` | `boolean` | `true` | Grow to `expandedHeight`: drag up on native, wheel up on the web. | | `expandedHeight` | `number` | window − status bar | Expanded height: fraction (≤ 1) or px. | | `closeOnBackdropPress` | `boolean` | `true` | Tap outside to dismiss. | +| `showHandle` | `boolean` | `true` | The grab bar at the top of a bottom sheet. Without it, only the header drags. | | `maxHeightRatio` | `number` | `0.9` | Max height as a fraction of the window. | | `height` | `number` | content | Fixed height: fraction (≤ 1) or px. | | `bottomInset` | `number` | `24` iOS / `12` | Bottom padding. | @@ -264,4 +265,6 @@ On iOS the sheet is wrapped in a `KeyboardAvoidingView`, so text inputs inside s ## Related - [PickerSheet](./picker-sheets#pickersheet): a BottomSheet with Cancel / Done buttons +- [ActionSheet](./action-sheet): a list of actions, such as Share or Delete +- [Migrate from @gorhom/bottom-sheet](../resources/migration/gorhom-bottom-sheet) or [react-native-modal](../resources/migration/react-native-modal) - [Filters sheet recipe](../recipes/filters-sheet) diff --git a/website/docs/guides/designs.mdx b/website/docs/guides/designs.mdx index f550cb0..eb0c4e2 100644 --- a/website/docs/guides/designs.mdx +++ b/website/docs/guides/designs.mdx @@ -73,6 +73,7 @@ export default function Example() { | Slider | a white knob on a thin track | a slim handle with gaps on a thick track, stop dots, a value bubble while dragging | a solid dot on a medium track, a halo on hover and press | | OtpField | bordered boxes, the focused one in the primary colour | outlined boxes, the focused one with a 2dp primary outline | compact outlined boxes, the focused one with a focus ring | | Rating | gold ★ stars | gold ★ stars | gold ★ stars; the pointer previews the rating a click would pick | +| ActionSheet | centered actions in a floating rounded card, Cancel in its own card | rows on the sheet with icons, no Cancel | rows, a centered modal on wide windows, no Cancel | | HorizontalList | a sliding raised thumb; filled chips | outlined segments and chips, the chosen one filled with a ✓ | a sliding raised thumb; filled chips | | Search fields | a short gray search bar | a tall pill | an outlined input with a focus ring | | EmojiPicker | categories at the bottom, the current one on a gray fill, UPPERCASE titles | categories at the bottom, the current one in a tinted pill | categories on top, underlined | diff --git a/website/docs/guides/styling.mdx b/website/docs/guides/styling.mdx index 238bd4f..a9f517a 100644 --- a/website/docs/guides/styling.mdx +++ b/website/docs/guides/styling.mdx @@ -61,6 +61,7 @@ type SlotStyles = Partial>>; | [SelectPicker](../components/select-picker#slots) | all SelectList slots + `trigger` `triggerText` `placeholder` `label` `chip` `chipText` `chevron` `clear` | | [HorizontalList](../components/horizontal-list#slots) | `container` `content` `item` `selectedItem` `itemLabel` `selectedItemLabel` `thumb` `arrow` `arrowText` | | [EmojiPicker](../components/emoji-picker#slots) | `container` `header` `searchInput` `skinToneButton` `skinToneMenu` `skinTonePopover` `categoryBar` `categoryTab` `activeCategoryTab` `categoryIndicator` `sectionHeader` `sectionHeaderText` `emojiCell` `emojiText` `empty` `emptyText` | +| [ActionSheet](../components/action-sheet#props) | `group` `header` `title` `message` `action` `actionLabel` `actionDescription` `check` `cancel` `cancelLabel` | | [BottomSheet](../components/bottom-sheet#slots) | `backdrop` `sheet` `handle` `header` `title` `content` `footer` | Picker sheets style their `BottomSheet` through `sheetProps={{ styles: { … } }}` and their picker through the picker's own `styles`. diff --git a/website/docs/guides/theming.mdx b/website/docs/guides/theming.mdx index 91e6463..bb0e26d 100644 --- a/website/docs/guides/theming.mdx +++ b/website/docs/guides/theming.mdx @@ -125,6 +125,7 @@ interface PickerTheme { handle: string; // sheet grab handle hover: string; // tint while the pointer is over anything pressable ripple: string; // ripple that spreads from the touch point on press + danger: string; // destructive actions, such as Delete in an action sheet }; radius: { sm: number; md: number; lg: number; full: number; sheet: number }; spacing: { xs: number; sm: number; md: number; lg: number; xl: number }; diff --git a/website/docs/guides/typescript.mdx b/website/docs/guides/typescript.mdx index 7f02ba7..d7aac5e 100644 --- a/website/docs/guides/typescript.mdx +++ b/website/docs/guides/typescript.mdx @@ -77,6 +77,7 @@ import type { // emoji EmojiPickerProps, EmojiPickerSlot, Emoji, EmojiCategory, SkinTone, // sheets + ActionSheetProps, ActionSheetAction, ActionSheetSlot, ActionSheetOptions, ActionSheetController, BottomSheetProps, BottomSheetSlot, SheetChromeProps, SheetPresentation, SelectPresentation, PickerSheetProps, DatePickerSheetProps, TimePickerSheetProps, DateTimePickerSheetProps, WheelPickerSheetProps, EmojiPickerSheetProps, DurationPickerSheetProps, MonthPickerSheetProps, ColorPickerSheetProps, diff --git a/website/docs/intro.mdx b/website/docs/intro.mdx index 74c6128..f39bfbc 100644 --- a/website/docs/intro.mdx +++ b/website/docs/intro.mdx @@ -2,7 +2,7 @@ title: React Native Pickers, zero native code sidebar_label: Introduction slug: /intro -description: "React Native Pickers: wheel, time, duration, date, month, week, range, date-time, select, country, phone, time zone, emoji and color pickers, a slider, a star rating and an OTP code input, with bottom sheets and form fields. Looks native on iOS, Android and the web, with zero native code. Fully themeable." +description: "React Native Pickers: wheel, time, duration, date, month, week, range, date-time, select, country, phone, time zone, emoji and color pickers, a slider, a star rating and an OTP code input, with bottom sheets, action sheets and form fields. Looks native on iOS, Android and the web, with zero native code. Fully themeable." --- # React Native Pickers @@ -95,6 +95,7 @@ Edit the code and the result updates instantly. Each example is a complete file | [`OtpField`](./components/otp-field) | A verification code in a box per character: SMS autofill, paste, a PIN mode | | [`Rating`](./components/rating) | Star rating: tap or drag, half steps, a read-only score, your own icon | | [`BottomSheet`](./components/bottom-sheet) | Animated sheet with drag-to-dismiss, for your own content | +| [`ActionSheet`](./components/action-sheet) | A list of actions such as Share or Delete, iOS cards or Material rows | | [`DatePickerSheet`, `TimePickerSheet`, `DateTimePickerSheet`, `DurationPickerSheet`, `MonthPickerSheet`, `ColorPickerSheet`, `WheelPickerSheet`, `EmojiPickerSheet`](./components/picker-sheets) | Ready-made pickers in a sheet with Cancel / Done and draft state | | [`DatePickerField`, `TimePickerField`, `DateTimePickerField`, `DurationPickerField`, `MonthPickerField`, `ColorPickerField`, `WheelPickerField`, `EmojiPickerField`, `PickerField`](./components/picker-fields) | Form fields that open a picker sheet and show the picked value, styled like `SelectPicker` | diff --git a/website/docs/resources/ai.mdx b/website/docs/resources/ai.mdx index 4b02aef..00f2771 100644 --- a/website/docs/resources/ai.mdx +++ b/website/docs/resources/ai.mdx @@ -93,7 +93,10 @@ OtpField value: string (the code so far) onChange(code) (typing, dele Rating value: number onChange(value) (tap, end of a drag, key/screen-reader step) max=5 step=1 (0.5: half stars) size=32 color='#FFB400' (filled) emptyColor=theme border readOnly (shows any fraction, e.g. 4.3; no touches) disabled renderIcon({index,filled,color,size}) (drawn empty + filled, cut to the value; default ★ text, no SVG) formatValue(v,max)='3 of 5' accessibilityLabel='Rating' adjustable for screen readers; arrows step, Home 0, End max; web: pointer previews the value; RTL; the row stretches like a View (stars at its start, taps past them ignored; alignSelf:'center' to centre); slots container item icon -BottomSheet visible onClose title headerLeft headerRight footer height(<=1 fraction | px) enableSwipeToClose draggable(header drags too; native:true web:false; handle always drags) expandable expandedHeight +BottomSheet visible onClose title headerLeft headerRight footer height(<=1 fraction | px) enableSwipeToClose draggable(header drags too; native:true web:false; handle always drags) expandable expandedHeight showHandle=true +ActionSheet visible onClose actions: {label, onPress?, description? (2nd line), icon?, selected? (✓), destructive? (theme colors.danger), disabled?, testID?}[] title message showCancel (default true on iOS design, false Material/web) cancelLabel='Cancel' presentation sheetProps + a tap calls onClose, then runs the action's onPress after the sheet has closed (so it can open another modal); iOS: grouped cards + separate Cancel; Material/web: rows with icons; slots group header title message action actionLabel actionDescription check cancel cancelLabel +useActionSheet() → { show(options), hide(), actionSheet }: render actionSheet once, show({title, actions, …ActionSheet props}) from any handler; no provider (like @expo/react-native-action-sheet's showActionSheetWithOptions) Sheets (draft; commit on Done): DatePickerSheet visible onClose value onConfirm + all calendar props + mode + display + confirmOnSelect TimePickerSheet visible onClose value onConfirm + TimePicker props diff --git a/website/docs/resources/migration/actions-sheet.mdx b/website/docs/resources/migration/actions-sheet.mdx new file mode 100644 index 0000000..a40e09f --- /dev/null +++ b/website/docs/resources/migration/actions-sheet.mdx @@ -0,0 +1,44 @@ +--- +title: Migrate from react-native-actions-sheet +sidebar_label: 'actions-sheet' +description: "Switch from react-native-actions-sheet to React Native Pickers: BottomSheet for your own content with visible instead of refs and SheetManager, or ActionSheet for a list of actions. Drag to close, expand, keyboard avoidance. No native code." +--- + +# Migrate from `react-native-actions-sheet` + +Despite its name, `react-native-actions-sheet` is a bottom sheet for any content. Your own content moves to [`BottomSheet`](../../components/bottom-sheet). A sheet that only lists actions (Share, Edit, Delete) is shorter as an [`ActionSheet`](../../components/action-sheet). + +```diff +- import ActionSheet, { type ActionSheetRef } from 'react-native-actions-sheet'; ++ import { BottomSheet } from '@elik56a/react-native-pickers'; + +- const ref = useRef(null); +- ref.current?.show(); ++ const [open, setOpen] = useState(false); ++ setOpen(true); + +- ++ setOpen(false)} onClosed={onClose}> + +- ++ +``` + +| react-native-actions-sheet | React Native Pickers | +| --- | --- | +| `ref.show()` / `ref.hide()` | `visible`, set to `false` in `onClose` | +| `SheetManager.show(id, { payload })` / `registerSheet` | render the sheet where you use it, pass the payload as props | +| `gestureEnabled` | `enableSwipeToClose` (default `true`) | +| `closeOnTouchBackdrop` | `closeOnBackdropPress` | +| `closeOnPressBack` | `onClose` is called on Android Back | +| `snapPoints={[50, 100]}` | `height={0.5}` and `expandedHeight={1}` | +| `onOpen` / `onClose` | `onOpened` / `onClosed`, after the animation | +| `containerStyle` | `styles={{ sheet }}` | +| `indicatorStyle` | `styles={{ handle }}` | +| `CustomHeaderComponent` / `headerAlwaysVisible` | `title`, `headerLeft`, `headerRight` | +| `keyboardHandlerEnabled` | built in | +| `ScrollView` / `FlatList` from the library | React Native's own, with a fixed `height` | +| `isModal={false}` (no backdrop, taps pass through) | — | +| `useSheetRouter` (pages inside one sheet) | — | + +See [all migration guides](./index.mdx) for other libraries. diff --git a/website/docs/resources/migration/expo-action-sheet.mdx b/website/docs/resources/migration/expo-action-sheet.mdx new file mode 100644 index 0000000..6b27574 --- /dev/null +++ b/website/docs/resources/migration/expo-action-sheet.mdx @@ -0,0 +1,63 @@ +--- +title: Migrate from @expo/react-native-action-sheet +sidebar_label: 'expo action-sheet' +description: "Switch from @expo/react-native-action-sheet to React Native Pickers: the same useActionSheet hook, an onPress per action instead of button indexes, destructive and disabled actions, icons, and the same look on iOS, Android and the web. No provider and no native code." +--- + +# Migrate from `@expo/react-native-action-sheet` + +Keep calling a function: `useActionSheet` has the same name and returns `show`, which opens [`ActionSheet`](../../components/action-sheet). Each action carries its own `onPress`, so there are no indexes to match up. Render the returned `actionSheet` once in the component, and remove `` from your app root. + +```diff +- import { useActionSheet } from '@expo/react-native-action-sheet'; ++ import { useActionSheet } from '@elik56a/react-native-pickers'; + +- const { showActionSheetWithOptions } = useActionSheet(); ++ const { show, actionSheet } = useActionSheet(); + const openMenu = () => +- showActionSheetWithOptions( +- { options: ['Share', 'Delete', 'Cancel'], destructiveButtonIndex: 1, cancelButtonIndex: 2, title: 'Photo' }, +- (index) => { +- if (index === 0) share(); +- if (index === 1) remove(); +- } +- ); ++ show({ ++ title: 'Photo', ++ actions: [ ++ { label: 'Share', onPress: share }, ++ { label: 'Delete', destructive: true, onPress: remove }, ++ ], ++ }); + + return ( + <> +