diff --git a/README.md b/README.md
index 8dfc168..6803739 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
[](https://www.npmjs.com/package/@elik56a/react-native-pickers) [](https://www.npmjs.com/package/@elik56a/react-native-pickers) [](https://bundlephobia.com/package/@elik56a/react-native-pickers) [](https://github.com/elik56a/react-native-pickers/actions/workflows/ci.yml) [](LICENSE) [](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.
@@ -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 (
+ <>
+
++ {actionSheet}
+ >
+ );
+```
+
+Prefer state? ` setOpen(false)} actions={…} />` takes the same props.
+
+| @expo/react-native-action-sheet | React Native Pickers |
+| --- | --- |
+| `showActionSheetWithOptions(options, callback)` | `show(options)` from `useActionSheet`, or `` |
+| `options` | `actions`: `{ label, onPress }` each |
+| `callback(index)` | each action's `onPress`, run after the sheet closes |
+| `cancelButtonIndex` | `showCancel` and `cancelLabel`; Cancel isn't one of the `actions` |
+| `destructiveButtonIndex` | `destructive: true` on the action |
+| `disabledButtonIndices` | `disabled: true` on the action |
+| `title` / `message` | `title` / `message` |
+| `icons` | `icon` on the action |
+| `cancelButtonTintColor` | `styles={{ cancelLabel: { color } }}` |
+| `tintColor` / `destructiveColor` | `theme={{ colors: { primary, danger } }}` |
+| `textStyle` / `titleTextStyle` / `messageTextStyle` | `styles={{ actionLabel, title, message }}` |
+| `containerStyle` | `styles={{ group }}` (iOS) or `sheetProps={{ styles: { sheet } }}` |
+| `showSeparators` / `separatorStyle` | separators come with the iOS design; `styles={{ action }}` |
+| `userInterfaceStyle` | `PickersProvider colorScheme` |
+| `anchor` (iPad popover) | — |
+
+On iOS, `@expo/react-native-action-sheet` shows the system `ActionSheetIOS`, and on Android and the web its own JavaScript sheet. `ActionSheet` draws the iOS design on iOS and Material on Android, so each platform still looks like itself, and you can set one `design` for all of them.
+
+See [all migration guides](./index.mdx) for other libraries.
diff --git a/website/docs/resources/migration/gorhom-bottom-sheet.mdx b/website/docs/resources/migration/gorhom-bottom-sheet.mdx
new file mode 100644
index 0000000..e3ad5dd
--- /dev/null
+++ b/website/docs/resources/migration/gorhom-bottom-sheet.mdx
@@ -0,0 +1,64 @@
+---
+title: Migrate from @gorhom/bottom-sheet
+sidebar_label: 'gorhom bottom-sheet'
+description: "Switch from @gorhom/bottom-sheet to React Native Pickers: a modal BottomSheet with drag to close, a second expanded height, keyboard avoidance and a centered modal on the web. No Reanimated, no Gesture Handler, no provider and no native code."
+---
+
+# Migrate from `@gorhom/bottom-sheet`
+
+`@gorhom/bottom-sheet` needs Reanimated and Gesture Handler. [`BottomSheet`](../../components/bottom-sheet) uses React Native's `Modal`, `Animated` and `PanResponder` only, so it covers the common case, a modal sheet over a backdrop, with nothing to install or configure. Check [what it doesn't do](#what-bottomsheet-doesnt-do) first.
+
+```diff
+- import { BottomSheetModal, BottomSheetModalProvider, BottomSheetView } from '@gorhom/bottom-sheet';
++ import { BottomSheet } from '@elik56a/react-native-pickers';
+
+- const ref = useRef(null);
+- ref.current?.present();
++ const [open, setOpen] = useState(false);
++ setOpen(true);
+
+-
+-
+-
+-
+-
++ setOpen(false)} height={0.5} expandedHeight={0.9} onClosed={onDismiss}>
++
++
+```
+
+Remove `` and `` if nothing else needs them.
+
+| @gorhom/bottom-sheet | React Native Pickers |
+| --- | --- |
+| `present()` / `dismiss()` / `close()` | `visible`, set to `false` in `onClose` |
+| `enableDynamicSizing` (default) | the default: the sheet fits its content, up to `maxHeightRatio` |
+| `snapPoints={['50%']}` | `height={0.5}`, or px: `height={400}` |
+| `snapPoints={['50%', '90%']}` | `height={0.5}` and `expandedHeight={0.9}`: drag up to expand |
+| `snapToIndex` / `expand()` / `collapse()` | — |
+| `enablePanDownToClose` | `enableSwipeToClose` (default `true`) |
+| `enableContentPanningGesture` / `enableHandlePanningGesture` | the content scrolls and never drags; the handle always drags, the header with `draggable` |
+| `onChange(index)` / `onAnimate` | `onOpened` / `onClosed` |
+| `onDismiss` | `onClosed` |
+| `backdropComponent={BottomSheetBackdrop}` | built in; `closeOnBackdropPress`, `styles={{ backdrop }}` |
+| `pressBehavior="none"` on the backdrop | `closeOnBackdropPress={false}` |
+| `handleComponent={null}` | `showHandle={false}` |
+| `handleIndicatorStyle` / `backgroundStyle` | `styles={{ handle }}` / `styles={{ sheet }}` |
+| `footerComponent` | `footer` |
+| `BottomSheetView` | a plain `View` |
+| `BottomSheetScrollView` / `BottomSheetFlatList` | React Native's `ScrollView` / `FlatList`, with a fixed `height` |
+| `BottomSheetTextInput` / `keyboardBehavior` | React Native's `TextInput`; the sheet avoids the keyboard |
+| `bottomInset` / `topInset` | `bottomInset` (padding for the home indicator) |
+
+On wide web windows the sheet becomes a centered modal by default (`presentation="auto"`); pass `presentation="bottomSheet"` to keep it a sheet.
+
+## What BottomSheet doesn't do
+
+Stay on `@gorhom/bottom-sheet` if you need:
+
+- a sheet that stays on screen with no backdrop while the screen behind it still scrolls and takes taps, like a map's place list (`BottomSheet` without `Modal`);
+- three or more snap points, or snapping from code (`snapToIndex`);
+- `animatedIndex` / `animatedPosition` to animate other views as the sheet moves;
+- detached sheets, or several modals stacked from one provider.
+
+See [all migration guides](./index.mdx) for other libraries, and [Comparison](../comparison.mdx) for when another library is the better choice.
diff --git a/website/docs/resources/migration/index.mdx b/website/docs/resources/migration/index.mdx
index e4443be..eb419b6 100644
--- a/website/docs/resources/migration/index.mdx
+++ b/website/docs/resources/migration/index.mdx
@@ -1,7 +1,7 @@
---
title: Migrate to React Native Pickers
sidebar_label: Overview
-description: "Prop-by-prop guides to move from datetimepicker, react-native-date-picker, react-native-calendars, dropdown and select libraries, the country and phone pickers, rn-emoji-keyboard, sliders, OTP inputs and star ratings to React Native Pickers."
+description: "Prop-by-prop guides to move from datetimepicker, react-native-date-picker, react-native-calendars, dropdown and select libraries, the country and phone pickers, rn-emoji-keyboard, sliders, OTP inputs, star ratings, bottom sheets, modals and action sheets to React Native Pickers."
---
# Migration
@@ -31,5 +31,9 @@ Compared with native pickers, your users keep a native look: the iOS design on i
| [`react-native-confirmation-code-field`](./confirmation-code-field.mdx) | `OtpField` |
| [`react-native-ratings`](./ratings.mdx) | `Rating` |
| [`react-native-star-rating-widget`](./star-rating-widget.mdx) | `Rating` |
+| [`@gorhom/bottom-sheet`](./gorhom-bottom-sheet.mdx) | `BottomSheet` |
+| [`react-native-modal`](./react-native-modal.mdx) | `BottomSheet` |
+| [`react-native-actions-sheet`](./actions-sheet.mdx) | `BottomSheet` or `ActionSheet` |
+| [`@expo/react-native-action-sheet`](./expo-action-sheet.mdx) | `ActionSheet` |
Not sure it's the right fit? [Comparison](../comparison.mdx) shows when another library is the better choice.
diff --git a/website/docs/resources/migration/react-native-modal.mdx b/website/docs/resources/migration/react-native-modal.mdx
new file mode 100644
index 0000000..3b61325
--- /dev/null
+++ b/website/docs/resources/migration/react-native-modal.mdx
@@ -0,0 +1,50 @@
+---
+title: Migrate from react-native-modal
+sidebar_label: 'react-native-modal'
+description: "Switch from react-native-modal to React Native Pickers: BottomSheet as a bottom sheet or a centered modal, with the backdrop, swipe to close, Back button and keyboard avoidance built in, and the sheet surface drawn for you. No native code."
+---
+
+# Migrate from `react-native-modal`
+
+Most `react-native-modal` code builds a bottom sheet or a dialog by hand: `style={{ justifyContent: 'flex-end', margin: 0 }}`, a white box with rounded corners, and the same close function passed to three callbacks. [`BottomSheet`](../../components/bottom-sheet) is that sheet: one `onClose` for every way of closing, and the surface, handle and padding drawn for you.
+
+```diff
+- import Modal from 'react-native-modal';
++ import { BottomSheet } from '@elik56a/react-native-pickers';
+
+-
+-
+-
+-
+-
++
++
++
+```
+
+For a centered dialog, the default `react-native-modal` look, pass `presentation="modal"`.
+
+| react-native-modal | React Native Pickers |
+| --- | --- |
+| `isVisible` | `visible` |
+| `onBackdropPress` / `onBackButtonPress` / `onSwipeComplete` | one `onClose` |
+| `swipeDirection="down"` | `enableSwipeToClose` (default `true`) |
+| `swipeDirection` `up`, `left` or `right` | — |
+| no `onBackdropPress` | `closeOnBackdropPress={false}` |
+| `style={{ justifyContent: 'flex-end', margin: 0 }}` | the default (`presentation="bottomSheet"`) |
+| a centered modal | `presentation="modal"` |
+| `backdropColor` / `backdropOpacity` | `styles={{ backdrop: { backgroundColor } }}`, or the theme's `backdrop` colour |
+| `animationIn` / `animationOut` / `animationInTiming` | slide up, or fade and rise as a modal; `theme={{ animation: { duration } }}` |
+| `onModalShow` / `onModalHide` | `onOpened` / `onClosed` |
+| `avoidKeyboard` | built in |
+| `propagateSwipe` | a `ScrollView` inside just scrolls; the handle and header drag the sheet |
+| `coverScreen={false}` / `hasBackdrop={false}` | — |
+
+See [all migration guides](./index.mdx) for other libraries.
diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts
index 556d198..2988c8f 100644
--- a/website/docusaurus.config.ts
+++ b/website/docusaurus.config.ts
@@ -38,7 +38,7 @@ const FOOTER_BRAND = `
${socialLink('LinkedIn', LINKEDIN, `${AUTHOR} on LinkedIn`)}
`;
const DESCRIPTION =
- 'Wheel, time, duration, date, calendar, month, week strip, range, date-time, select, country, phone, time zone, emoji and color pickers, a slider, a star rating and an OTP code input for React Native, with bottom sheets and form fields. Looks native on iOS, Android and the web, with zero native code. Fully themeable; works in Expo Go, bare RN and React Native Web.';
+ 'Wheel, time, duration, date, calendar, month, week strip, range, date-time, select, country, phone, time zone, emoji and color pickers, a slider, a star rating and an OTP code input for React Native, with bottom sheets, action sheets and form fields. Looks native on iOS, Android and the web, with zero native code. Fully themeable; works in Expo Go, bare RN and React Native Web.';
const local = (pkg: string) => path.dirname(require.resolve(`${pkg}/package.json`));
const { version } = require('../package.json');
@@ -66,7 +66,7 @@ const structuredData = {
version,
author: { '@type': 'Person', name: AUTHOR, url: 'https://github.com/elik56a', sameAs: [LINKEDIN] },
keywords:
- 'react native date picker, time picker, duration picker, month picker, week strip, date time picker, wheel picker, calendar, date range picker, select, dropdown, inline picker, multi-select, autocomplete, segmented control, country picker, phone code picker, phone number input, time zone picker, emoji picker, color picker, slider, range slider, otp input, verification code input, star rating, bottom sheet, material design, ios picker, expo, react native web, keyboard accessible',
+ 'react native date picker, time picker, duration picker, month picker, week strip, date time picker, wheel picker, calendar, date range picker, select, dropdown, inline picker, multi-select, autocomplete, segmented control, country picker, phone code picker, phone number input, time zone picker, emoji picker, color picker, slider, range slider, otp input, verification code input, star rating, bottom sheet, action sheet, material design, ios picker, expo, react native web, keyboard accessible',
},
],
};
@@ -91,7 +91,7 @@ const config: Config = {
attributes: {
name: 'keywords',
content:
- 'react native picker, react native date picker, react native time picker, react native duration picker, countdown timer picker, react native month picker, month year picker, credit card expiry picker, react native week calendar strip, react native date time picker, react native wheel picker, react native calendar, date range picker, react native select, react native dropdown, multi-select, autocomplete, segmented control, chips, react native country picker, country code picker, emoji picker, react native color picker, hex color picker, react native slider, range slider, react native otp input, otp verification code input, sms code autofill, react native star rating, rating stars, bottom sheet, material design date picker, material time picker clock, ios style picker, expo, react native web',
+ 'react native picker, react native date picker, react native time picker, react native duration picker, countdown timer picker, react native month picker, month year picker, credit card expiry picker, react native week calendar strip, react native date time picker, react native wheel picker, react native calendar, date range picker, react native select, react native dropdown, multi-select, autocomplete, segmented control, chips, react native country picker, country code picker, emoji picker, react native color picker, hex color picker, react native slider, range slider, react native otp input, otp verification code input, sms code autofill, react native star rating, rating stars, bottom sheet, react native bottom sheet, react native modal, react native action sheet, material design date picker, material time picker clock, ios style picker, expo, react native web',
},
},
{ tagName: 'link', attributes: { rel: 'alternate', type: 'text/plain', title: 'LLM docs', href: `${BASE_URL}llms.txt` } },
diff --git a/website/sidebars.ts b/website/sidebars.ts
index fc3e83e..475da60 100644
--- a/website/sidebars.ts
+++ b/website/sidebars.ts
@@ -114,6 +114,10 @@ const sidebars: SidebarsConfig = {
'resources/migration/ratings',
'resources/migration/star-rating-widget',
'resources/migration/confirmation-code-field',
+ 'resources/migration/gorhom-bottom-sheet',
+ 'resources/migration/react-native-modal',
+ 'resources/migration/actions-sheet',
+ 'resources/migration/expo-action-sheet',
],
},
'resources/faq',
diff --git a/website/src/catalog/catalog.constants.ts b/website/src/catalog/catalog.constants.ts
index 3b3fcb2..ec5b430 100644
--- a/website/src/catalog/catalog.constants.ts
+++ b/website/src/catalog/catalog.constants.ts
@@ -36,6 +36,7 @@ export const PLAYGROUND = {
pickerField: 'pickerField',
pickerSheet: 'pickerSheet',
bottomSheet: 'bottomSheet',
+ actionSheet: 'actionSheet',
} as const;
/**
@@ -108,6 +109,7 @@ export const CATALOG: readonly CatalogEntry[] = [
{ doc: 'components/picker-fields', label: 'Picker fields', playgrounds: [PLAYGROUND.pickerField] },
{ doc: 'components/picker-sheets', label: 'Picker sheets', playgrounds: [PLAYGROUND.pickerSheet] },
{ doc: 'components/bottom-sheet', label: 'BottomSheet', playgrounds: [PLAYGROUND.bottomSheet] },
+ { doc: 'components/action-sheet', label: 'ActionSheet', playgrounds: [PLAYGROUND.actionSheet] },
],
},
];
diff --git a/website/src/catalog/playgrounds.tsx b/website/src/catalog/playgrounds.tsx
index b96635f..6510fd1 100644
--- a/website/src/catalog/playgrounds.tsx
+++ b/website/src/catalog/playgrounds.tsx
@@ -1,6 +1,7 @@
import { Text, View } from 'react-native';
import {
+ ActionSheet,
BottomSheet,
ColorPicker,
ColorPickerField,
@@ -552,4 +553,18 @@ export const PLAYGROUNDS: Record = {
),
},
+ actionSheet: {
+ name: 'ActionSheet',
+ component: ActionSheet,
+ opener: 'Photo options',
+ props: {
+ title: 'Profile photo',
+ actions: [{ label: 'Take photo' }, { label: 'Choose from library' }, { label: 'Remove photo', destructive: true }],
+ },
+ code: 'visible={open} onClose={() => setOpen(false)} title="Profile photo" actions={actions}',
+ controls: [
+ { prop: 'presentation', options: ['auto', 'bottomSheet', 'modal'] },
+ { prop: 'showCancel', options: ['auto', true, false], unset: 'auto' },
+ ],
+ },
};
diff --git a/website/src/home/components/Hero.tsx b/website/src/home/components/Hero.tsx
index 5e04aa9..390ef24 100644
--- a/website/src/home/components/Hero.tsx
+++ b/website/src/home/components/Hero.tsx
@@ -30,8 +30,8 @@ export const Hero = () => {
Wheel, time, duration, date, month, range, select, country, phone, time zone, emoji and color pickers, a slider, a rating, an
- OTP input, plus the sheets and fields to put them in. Native on iOS, Android and the web, yours to customize, and{' '}
- 100% JavaScript with zero native code.
+ OTP input, plus bottom sheets, action sheets and fields to put them in. Native on iOS, Android and the web, yours to customize,
+ and 100% JavaScript with zero native code.
diff --git a/website/src/home/home.constants.ts b/website/src/home/home.constants.ts
index 96cc9cf..7202c2c 100644
--- a/website/src/home/home.constants.ts
+++ b/website/src/home/home.constants.ts
@@ -13,7 +13,7 @@ export const NPM_DOWNLOADS_STATUS = {
export const PAGE_TITLE = 'Date, Time, Select & Emoji Pickers with Zero Native Code';
export const PAGE_DESCRIPTION =
- '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 for React Native, with bottom sheets and form fields. Looks native on iOS, Android and the web, with zero native code. Fully themeable; works in Expo Go, bare RN and React Native Web.';
+ '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 for React Native, with bottom sheets, action sheets and form fields. Looks native on iOS, Android and the web, with zero native code. Fully themeable; works in Expo Go, bare RN and React Native Web.';
export const COUNT_UP_MS = 1400;
export const COPIED_MS = 1500;
@@ -73,6 +73,7 @@ export const COMPONENTS = [
{ doc: 'components/rating', name: 'Rating', body: 'Stars to tap or drag, half steps, scores' },
{ doc: 'components/multi-wheel-picker', name: 'MultiWheelPicker', body: 'Units, compound values, dependent columns' },
{ doc: 'components/picker-sheets', name: 'Sheets', body: 'Every picker in a bottom sheet or modal' },
+ { doc: 'components/action-sheet', name: 'ActionSheet', body: 'Share, edit, delete: iOS cards or Material rows' },
{ doc: 'components/picker-fields', name: 'Fields', body: 'Tap a field, pick in a sheet, a dropdown or inline' },
];
diff --git a/website/src/showcase/ShowcaseApp.tsx b/website/src/showcase/ShowcaseApp.tsx
index 95f7696..0b4ff60 100644
--- a/website/src/showcase/ShowcaseApp.tsx
+++ b/website/src/showcase/ShowcaseApp.tsx
@@ -4,6 +4,7 @@ import type { PickersProviderProps } from '@elik56a/react-native-pickers';
import { PickersProvider } from '@elik56a/react-native-pickers';
import { BookingScreen } from '../home/demo/components/BookingScreen';
import { AlarmScreen } from './components/AlarmScreen';
+import { AlbumsScreen } from './components/AlbumsScreen';
import { ChatScreen } from './components/ChatScreen';
import { DeliveryScreen } from './components/DeliveryScreen';
import { DisplayScreen } from './components/DisplayScreen';
@@ -35,6 +36,7 @@ const SCREENS: Record> = {
display: DisplayScreen,
verify: VerifyScreen,
review: ReviewScreen,
+ albums: AlbumsScreen,
};
interface ShowcaseAppProps extends ScreenProps {
diff --git a/website/src/showcase/components/AlbumsScreen.tsx b/website/src/showcase/components/AlbumsScreen.tsx
new file mode 100644
index 0000000..fd27283
--- /dev/null
+++ b/website/src/showcase/components/AlbumsScreen.tsx
@@ -0,0 +1,73 @@
+import { useState } from 'react';
+import { Pressable, Text, View } from 'react-native';
+
+import { ActionSheet, useActionSheet, usePickerTheme } from '@elik56a/react-native-pickers';
+import { AppScreen } from '../../home/demo/components/AppScreen';
+import { deviceSheetProps } from '../../home/demo/demo.utils';
+import { ALBUM_SORTS, ALBUMS } from '../showcase.constants';
+import { styles } from '../showcase.styles';
+import type { AlbumSort, ScreenProps } from '../showcase.types';
+import { sortAlbums } from '../showcase.utils';
+
+export const AlbumsScreen = ({ platform, screenHeight }: ScreenProps) => {
+ const { colors } = usePickerTheme();
+ const sheetProps = deviceSheetProps(platform, screenHeight);
+ const [albums, setAlbums] = useState(ALBUMS);
+ const [sort, setSort] = useState(ALBUM_SORTS[0]);
+ const [sorting, setSorting] = useState(false);
+ const [notice, setNotice] = useState('Tap ⋯ on an album');
+ const { show, actionSheet } = useActionSheet();
+
+ const openMenu = (name: string) =>
+ show({
+ title: name,
+ sheetProps,
+ actions: [
+ { label: 'Share', description: 'Send a link to the album', onPress: () => setNotice(`Shared “${name}”`) },
+ { label: 'Rename', onPress: () => setNotice(`Renaming “${name}”`) },
+ {
+ label: 'Delete album',
+ destructive: true,
+ onPress: () => {
+ setAlbums((prev) => prev.filter((album) => album.name !== name));
+ setNotice(`Deleted “${name}”`);
+ },
+ },
+ ],
+ });
+
+ return (
+
+ setSorting(true)} accessibilityRole="button">
+ Sort by
+ {sort} ▾
+
+ {sortAlbums(albums, sort).map((album) => (
+
+
+ {album.emoji}
+
+
+ {album.name}
+ {album.photos} photos
+
+ openMenu(album.name)} accessibilityRole="button" accessibilityLabel={`${album.name} options`}>
+ ⋯
+
+
+ ))}
+ setSorting(false)}
+ title="Sort albums by"
+ sheetProps={sheetProps}
+ actions={ALBUM_SORTS.map((label) => ({
+ label,
+ selected: label === sort,
+ onPress: () => setSort(label),
+ }))}
+ />
+ {actionSheet}
+
+ );
+};
diff --git a/website/src/showcase/showcase.constants.ts b/website/src/showcase/showcase.constants.ts
index 8151a63..3120c5f 100644
--- a/website/src/showcase/showcase.constants.ts
+++ b/website/src/showcase/showcase.constants.ts
@@ -1,6 +1,6 @@
import type { CountryCode } from '@elik56a/react-native-pickers';
import { labelOptions } from '../home/demo/demo.utils';
-import type { ChatMessage, ShowcaseItem, TimerPreset } from './showcase.types';
+import type { Album, ChatMessage, ShowcaseItem, TimerPreset } from './showcase.types';
export const SHOWCASE_SCREEN = {
booking: 'booking',
@@ -18,6 +18,7 @@ export const SHOWCASE_SCREEN = {
display: 'display',
verify: 'verify',
review: 'review',
+ albums: 'albums',
} as const;
export const SHOWCASE: ShowcaseItem[] = [
@@ -114,6 +115,12 @@ export const SHOWCASE: ShowcaseItem[] = [
blurb: 'Half stars for the stay, a row of stars per category, and the place’s average shown read-only.',
components: ['Rating'],
},
+ {
+ key: SHOWCASE_SCREEN.albums,
+ title: 'Photo albums',
+ blurb: 'Sort the albums from a sheet that ticks the current order, and open each album’s ⋯ menu to share, rename or delete it.',
+ components: ['ActionSheet', 'useActionSheet'],
+ },
{
key: SHOWCASE_SCREEN.trips,
title: 'Trip planner',
@@ -316,3 +323,18 @@ export const REVIEW = {
reviews: 312,
} as const;
export const REVIEW_CATEGORIES = ['Cleanliness', 'Location', 'Value'] as const;
+
+export const ALBUMS: Album[] = [
+ { name: 'Summer in Lisbon', emoji: '🌊', photos: 214 },
+ { name: 'Maya’s birthday', emoji: '🎂', photos: 86 },
+ { name: 'Hiking the Alps', emoji: '🏔️', photos: 152 },
+ { name: 'Recipes', emoji: '🍝', photos: 37 },
+];
+
+export const ALBUM_SORT = {
+ recent: 'Recently added',
+ name: 'Name',
+ photos: 'Most photos',
+} as const;
+
+export const ALBUM_SORTS = Object.values(ALBUM_SORT);
diff --git a/website/src/showcase/showcase.styles.ts b/website/src/showcase/showcase.styles.ts
index 14d21c3..201a01f 100644
--- a/website/src/showcase/showcase.styles.ts
+++ b/website/src/showcase/showcase.styles.ts
@@ -46,4 +46,8 @@ export const styles = StyleSheet.create({
hint: { fontSize: 13 },
scoreRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },
categoryRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
+ albumRow: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 8 },
+ albumCover: { width: 44, height: 44, borderRadius: 10, alignItems: 'center', justifyContent: 'center' },
+ albumText: { flex: 1 },
+ more: { fontSize: 20, paddingHorizontal: 8 },
});
diff --git a/website/src/showcase/showcase.types.ts b/website/src/showcase/showcase.types.ts
index f80ca9e..0ca3c33 100644
--- a/website/src/showcase/showcase.types.ts
+++ b/website/src/showcase/showcase.types.ts
@@ -2,12 +2,20 @@ import type { ReactNode } from 'react';
import type { BookingScreenProps } from '../home/demo/demo.types';
import type { ValueOf } from '../home/home.types';
-import type { SHOWCASE_SCREEN, TRIP_TYPE } from './showcase.constants';
+import type { ALBUM_SORT, SHOWCASE_SCREEN, TRIP_TYPE } from './showcase.constants';
export type ShowcaseScreenKey = ValueOf;
export type TripType = ValueOf;
+export type AlbumSort = ValueOf;
+
+export interface Album {
+ name: string;
+ emoji: string;
+ photos: number;
+}
+
export interface ShowcaseItem {
key: ShowcaseScreenKey;
title: string;
diff --git a/website/src/showcase/showcase.utils.test.ts b/website/src/showcase/showcase.utils.test.ts
index 9764a5a..5c7100f 100644
--- a/website/src/showcase/showcase.utils.test.ts
+++ b/website/src/showcase/showcase.utils.test.ts
@@ -1,5 +1,14 @@
-import { CLOSED_WEEKDAY, TIMER_PRESETS } from './showcase.constants';
-import { findPreset, formatClock, formatHour, insertAtSelection, isClosedDay, previewOpacity, textSizeLabel } from './showcase.utils';
+import { ALBUM_SORT, ALBUMS, CLOSED_WEEKDAY, TIMER_PRESETS } from './showcase.constants';
+import {
+ findPreset,
+ formatClock,
+ formatHour,
+ insertAtSelection,
+ isClosedDay,
+ previewOpacity,
+ sortAlbums,
+ textSizeLabel,
+} from './showcase.utils';
describe('formatClock', () => {
it('reads like a clock, with hours only once there are some', () => {
@@ -62,3 +71,11 @@ describe('textSizeLabel', () => {
expect(textSizeLabel(16)).toBe('16 pt');
});
});
+
+describe('sortAlbums', () => {
+ it('sorts by name or by photo count, and keeps the list order for recent', () => {
+ expect(sortAlbums(ALBUMS, ALBUM_SORT.name)[0].name).toBe('Hiking the Alps');
+ expect(sortAlbums(ALBUMS, ALBUM_SORT.photos)[0].photos).toBe(214);
+ expect(sortAlbums(ALBUMS, ALBUM_SORT.recent)).toBe(ALBUMS);
+ });
+});
diff --git a/website/src/showcase/showcase.utils.ts b/website/src/showcase/showcase.utils.ts
index d568536..ec53ac8 100644
--- a/website/src/showcase/showcase.utils.ts
+++ b/website/src/showcase/showcase.utils.ts
@@ -1,5 +1,5 @@
-import { CLOSED_WEEKDAY, HOURS_IN_DAY, MIN_PREVIEW_OPACITY, TEXT_SIZES, TIMER_PRESETS } from './showcase.constants';
-import type { TextSelection } from './showcase.types';
+import { ALBUM_SORT, CLOSED_WEEKDAY, HOURS_IN_DAY, MIN_PREVIEW_OPACITY, TEXT_SIZES, TIMER_PRESETS } from './showcase.constants';
+import type { Album, AlbumSort, TextSelection } from './showcase.types';
const pad = (n: number) => String(n).padStart(2, '0');
@@ -39,3 +39,10 @@ export const previewOpacity = (brightness: number) => MIN_PREVIEW_OPACITY + ((1
/** A text size's name on its mark, such as M for 17 pt. */
export const textSizeLabel = (size: number) => TEXT_SIZES.find((option) => option.value === size)?.label ?? `${size} pt`;
+
+/** Albums in the chosen order; `recent` keeps the list's own order, newest first. */
+export const sortAlbums = (albums: Album[], sort: AlbumSort) => {
+ if (sort === ALBUM_SORT.name) return [...albums].sort((a, b) => a.name.localeCompare(b.name));
+ if (sort === ALBUM_SORT.photos) return [...albums].sort((a, b) => b.photos - a.photos);
+ return albums;
+};