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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ website/.docusaurus/
website/versioned_docs/
website/versioned_sidebars/
website/versions.json
website/archived-versions.json
website/src/pages/changelog.md

# The demo GIF recorder and the page it records: local only, see reel/README.md
Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

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

**Every picker a React Native app needs, in one package with zero native code.** Wheel, time, duration, date, month, week strip, date range, date & time, select, country, phone number, time zone, segmented control, emoji and color pickers, a slider 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 the bottom sheets and form fields to put them in. Native on iOS, Android and the web, and every detail is yours to customize.

<p align="center">
<img alt="React Native Pickers on an iPhone and an Android phone side by side: a date range picker with prices, wheel and clock-dial time pickers, a searchable select, an emoji picker, a phone number field, a color picker, a duration picker, and light and dark mode" src="https://raw.githubusercontent.com/elik56a/react-native-pickers/master/website/static/img/demo.gif" width="900">
Expand Down Expand Up @@ -107,6 +107,7 @@ Each field opens its picker in a bottom sheet on iOS and Android and in a dropdo
| [`ColorPicker`](https://react-native-pickers.dev/docs/components/color-picker) | iOS's Grid / Spectrum / Sliders, Google's swatch circles, or the browser's spectrum; hex with opacity and a copy button |
| [`Slider`](https://react-native-pickers.dev/docs/components/slider) | iOS's knob, Material 3's handle with a value bubble, or a range input; one thumb or a range, steps, icons |
| [`OtpField`](https://react-native-pickers.dev/docs/components/otp-field) | A verification code in a box per character: SMS autofill on iOS, Android and the web, paste, a PIN mode |
| [`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 |
| [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 |
Expand Down Expand Up @@ -316,6 +317,17 @@ The [full API reference](https://react-native-pickers.dev/docs/api/reference) li
| `inputMode` | `'numeric' \| 'text'` | `'numeric'` |
| `secureTextEntry` | `boolean`: dots, for a PIN | `false` |

### Rating

| Prop | Type | Default |
|---|---|---|
| `value` | `number` | `0` |
| `onChange` | `(value) => void`: on a tap, the end of a drag, or a key step | |
| `max` / `step` | `number`; `step={0.5}` for half stars | `5` / `1` |
| `readOnly` | `boolean`: shows any fraction, no touches | `false` |
| `size` / `color` / `emptyColor` | star size and colours | `32` / gold / theme border |
| `renderIcon` | `({ index, filled, color, size }) => ReactNode` | ★ |

### DurationPicker

A length of time, such as a timer or a workout, rather than a time of day. The value is a number of seconds.
Expand Down
2 changes: 1 addition & 1 deletion context7.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"url": "https://context7.com/elik56a/react-native-pickers",
"public_key": "pk_EfgECmIIv32CQlZpPe9GW",
"projectTitle": "React Native Pickers",
"description": "Wheel, time, duration, date, month, week strip, date-time, select, country, phone number, time zone, horizontal list, emoji and color pickers, a slider, 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 and form fields for React Native, Expo and React Native Web. Looks native on iOS, Android and the web, with zero native code (pure JavaScript).",
"folders": ["website/docs"],
"rules": [
"Install @elik56a/react-native-pickers. It has zero native code (pure JavaScript), so it works in Expo Go without a rebuild.",
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@elik56a/react-native-pickers",
"version": "0.27.0",
"description": "Fully customizable React Native pickers with zero native code: wheel, time, duration, date (calendar, range, wheel), month, week strip, date-time, select (with server search), country (flags and dialing codes), phone number, time zone, segmented control, emoji and color, a slider (single or range), 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 and form fields. Native iOS, Material and web designs. No native code, works with Expo and React Native Web.",
"license": "MIT",
"author": "Eliyahu Kriel <elik56a@gmail.com>",
"homepage": "https://react-native-pickers.dev/",
Expand Down Expand Up @@ -76,6 +76,8 @@
"range-slider",
"otp-input",
"otp",
"rating",
"star-rating",
"verification-code",
"colour-picker",
"hex-color-picker",
Expand Down
8 changes: 7 additions & 1 deletion src/core/utils/track.utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { clamp, clamp01, toFraction } from './track.utils';
import { clamp, clamp01, roundTo, stepDecimals, toFraction } from './track.utils';

describe('track utils', () => {
it('clamps into a range', () => {
Expand All @@ -14,4 +14,10 @@ describe('track utils', () => {
expect(toFraction(300, 200)).toBe(1);
expect(toFraction(50, 0)).toBe(0);
});

it('rounds away float noise, to as many decimals as a step has', () => {
expect(roundTo(0.1 + 0.2, 1)).toBe(0.3);
expect(stepDecimals(0.25)).toBe(2);
expect(stepDecimals(5)).toBe(0);
});
});
6 changes: 6 additions & 0 deletions src/core/utils/track.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,11 @@ export const clamp = (n: number, min: number, max: number) => Math.min(max, Math

export const clamp01 = (n: number) => clamp(n, 0, 1);

/** Rounds away float noise, so 0.1 + 0.2 lands on 0.3. */
export const roundTo = (n: number, decimals: number) => Number(n.toFixed(decimals));

/** How many decimals a step has: 0.25 → 2, 5 → 0. */
export const stepDecimals = (step: number) => String(step).split('.')[1]?.length ?? 0;

/** A value between 0 and 1 from a touch `at` along a length. */
export const toFraction = (at: number, length: number) => (length > 0 ? clamp01(at / length) : 0);
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export * from './emoji';
export * from './list';
export * from './month';
export * from './otp';
export * from './rating';
export * from './select';
export * from './sheet';
export * from './slider';
Expand Down
99 changes: 99 additions & 0 deletions src/rating/Rating.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import type React from 'react';
import type { GestureResponderEvent, PanResponderCallbacks, PanResponderGestureState } from 'react-native';
import { PanResponder, StyleSheet, Text } from 'react-native';

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

import { Rating } from './Rating';

const touchAt = (x: number) => ({ nativeEvent: { locationX: x, locationY: 22 } }) as unknown as GestureResponderEvent;
const moved = (dx: number) =>
({
dx,
dy: 0,
}) as PanResponderGestureState;
const key = (k: string) => ({
nativeEvent: { key: k },
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
});
/** How wide the filled star over star `index` is, in px; 0 when it's empty. */
const fillOf = (index: number) => {
const fill = screen.getByTestId(`rating-star-${index}`).props.children[1];
return fill ? StyleSheet.flatten(fill.props.style).width : 0;
};

/** Renders the row laid out as five 32 px stars 4 px apart: each star starts every 36 px. */
const renderRating = async (ui: React.ReactElement, width = 176) => {
const spy = jest.spyOn(PanResponder, 'create');
await render(ui);
await fireEvent(screen.getByTestId('rating'), 'layout', { nativeEvent: { layout: { x: 0, y: 0, width, height: 44 } } });
return spy.mock.calls[0]?.[0] as PanResponderCallbacks;
};

describe('Rating', () => {
afterEach(() => jest.restoreAllMocks());

it('rates the star tapped', async () => {
const onChange = jest.fn();
const pan = await renderRating(<Rating onChange={onChange} testID="rating" />);
await act(() => pan.onPanResponderGrant?.(touchAt(80), moved(0)));
await act(() => pan.onPanResponderRelease?.(touchAt(80), moved(0)));
expect(onChange).toHaveBeenCalledWith(3);
});

it('ignores a tap in the empty row after the stars', async () => {
const onChange = jest.fn();
const pan = await renderRating(<Rating onChange={onChange} testID="rating" />, 300);
await act(() => pan.onPanResponderGrant?.(touchAt(250), moved(0)));
await act(() => pan.onPanResponderRelease?.(touchAt(250), moved(0)));
expect(onChange).not.toHaveBeenCalled();
});

it('previews a drag and commits once on release, in half steps', async () => {
const onChange = jest.fn();
const pan = await renderRating(<Rating step={0.5} onChange={onChange} testID="rating" />);
const start = touchAt(10);
await act(() => pan.onPanResponderGrant?.(start, moved(0)));
await act(() => pan.onPanResponderMove?.(start, moved(105)));
expect(onChange).not.toHaveBeenCalled();
expect(fillOf(3)).toBe(16);
await act(() => pan.onPanResponderRelease?.(start, moved(105)));
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(3.5);
});

it('steps with keys and screen-reader actions', async () => {
const onChange = jest.fn();
await render(<Rating defaultValue={3} onChange={onChange} testID="rating" />);
const row = screen.getByRole('adjustable', { name: 'Rating' });
expect(row.props.accessibilityValue).toEqual({ text: '3 of 5' });
await fireEvent(row, 'keyDown', key('ArrowRight'));
expect(onChange).toHaveBeenLastCalledWith(4);
await fireEvent(row, 'accessibilityAction', { nativeEvent: { actionName: 'decrement' } });
expect(onChange).toHaveBeenLastCalledWith(3);
await fireEvent(row, 'keyDown', key('Home'));
expect(onChange).toHaveBeenLastCalledWith(0);
});

it('shows any fraction read-only and takes no touches', async () => {
await render(<Rating value={4.25} readOnly testID="rating" />);
expect(fillOf(4)).toBe(8);
expect(screen.getByTestId('rating').props.onResponderGrant).toBeUndefined();
expect(screen.getByLabelText('Rating').props.accessibilityValue).toEqual({ text: '4.25 of 5' });
});

it('draws a custom icon empty and filled', async () => {
await render(<Rating defaultValue={1} max={2} renderIcon={({ filled }) => <Text>{filled ? '♥' : '♡'}</Text>} />);
expect(screen.getAllByText('♥')).toHaveLength(1);
expect(screen.getAllByText('♡')).toHaveLength(2);
});

it('follows a controlled value without reporting it back', async () => {
const onChange = jest.fn();
const { rerender } = await render(<Rating value={1} onChange={onChange} testID="rating" />);
await rerender(<Rating value={4} onChange={onChange} testID="rating" />);
expect(screen.getByRole('adjustable').props.accessibilityValue).toEqual({ text: '4 of 5' });
expect(onChange).not.toHaveBeenCalled();
});
});
51 changes: 51 additions & 0 deletions src/rating/Rating.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { View } from 'react-native';

import { usePickerTheme } from '../core';
import { useDesignOverride } from '../core/theme/ThemeProvider';
import { RatingItem } from './components/RatingItem';
import { useRating } from './hooks/useRating';
import { DEFAULT_RATING_SIZE, RATING_COLOR } from './rating.constants';
import { styles } from './rating.styles';
import type { RatingProps } from './rating.types';

/** A row of stars to rate with: tap or drag across them, in whole or half steps, or show a score read-only. */
export const Rating = (props: RatingProps) => {
const {
size = DEFAULT_RATING_SIZE,
color = RATING_COLOR,
emptyColor,
renderIcon,
disabled,
style,
styles: slots,
theme: themeProp,
design,
testID,
} = props;
const theme = usePickerTheme(useDesignOverride(themeProp, design));
const rating = useRating(props, theme);

return (
<View
style={[styles.row, { gap: rating.gap }, rating.interactive && styles.pointer, disabled && styles.disabled, slots?.container, style]}
onLayout={rating.onLayout}
{...rating.panHandlers}
{...rating.hoverHandlers}
{...rating.a11y}
testID={testID}
>
{rating.items.map((item) => (
<RatingItem
key={item.key}
item={item}
size={size}
color={color}
emptyColor={emptyColor ?? theme.colors.border}
renderIcon={renderIcon}
slots={slots}
testID={testID ? `${testID}-star-${item.index}` : undefined}
/>
))}
</View>
);
};
15 changes: 15 additions & 0 deletions src/rating/components/RatingIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Text } from 'react-native';

import { STAR_GLYPH } from '../rating.constants';
import { styles } from '../rating.styles';
import type { RatingIconProps } from '../rating.types';

/** The custom icon, or a ★ in the star's colour. */
export const RatingIcon = ({ info, renderIcon, slots }: RatingIconProps) =>
renderIcon ? (
renderIcon(info)
) : (
<Text style={[styles.glyph, { width: info.size, fontSize: info.size, lineHeight: info.size, color: info.color }, slots?.icon]}>
{STAR_GLYPH}
</Text>
);
23 changes: 23 additions & 0 deletions src/rating/components/RatingItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { View } from 'react-native';

import { styles } from '../rating.styles';
import type { RatingItemProps } from '../rating.types';
import { RatingIcon } from './RatingIcon';

/** An empty star, with the filled one over it cut to how much of it the rating reaches. Touches go to the row. */
export const RatingItem = ({ item, size, color, emptyColor, renderIcon, slots, testID }: RatingItemProps) => {
const box = { width: size, height: size };

return (
<View pointerEvents="none" style={[box, slots?.item]} testID={testID}>
<RatingIcon info={{ index: item.index, filled: false, color: emptyColor, size }} renderIcon={renderIcon} slots={slots} />
{item.fill > 0 ? (
<View style={[styles.fill, { width: item.fill * size }]}>
<View style={box}>
<RatingIcon info={{ index: item.index, filled: true, color, size }} renderIcon={renderIcon} slots={slots} />
</View>
</View>
) : null}
</View>
);
};
Loading