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
17 changes: 16 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, 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 and a slider, 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 @@ -105,6 +105,7 @@ Each field opens its picker in a bottom sheet on iOS and Android and in a dropdo
| [`TimeZonePicker`](https://react-native-pickers.dev/docs/components/time-zone-picker) | Every IANA time zone with its GMT offset, sorted by offset, the device's zone on top |
| [`HorizontalList`](https://react-native-pickers.dev/docs/components/horizontal-list) | Segmented control or chips that scroll sideways and reveal the next item |
| [`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 |
| [`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 @@ -289,6 +290,20 @@ The [full API reference](https://react-native-pickers.dev/docs/api/reference) li

`ColorPickerField` shows a swatch and the hex code.

### Slider

| Prop | Type | Default |
|---|---|---|
| `value` | `number`, or `[number, number]` with `range` | `min` |
| `onChange` | `(value) => void`: on release, or a key or screen-reader step | |
| `onValueChanging` | `(value) => void`: every step while dragging | |
| `min` / `max` / `step` | `number`; `step={0}` for any value | `0` / `100` / `1` |
| `range` | `boolean`: two thumbs | `false` |
| `showSteps` | `boolean`: a dot per step | `false` |
| `marks` / `snapToMarks` | `(number \| { value, label? })[]`: dots with labels / only the marks can be picked | / `false` |
| `valueLabel` | `'never' \| 'drag' \| 'always'`: the value bubble | `'drag'` in Material |
| `minimumIcon` / `maximumIcon` | `ReactNode` beside the track | |

### 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 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 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.25.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, 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), 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 @@ -72,6 +72,8 @@
"multi-select",
"emoji-keyboard",
"color-picker",
"slider",
"range-slider",
"colour-picker",
"hex-color-picker",
"duration-picker",
Expand Down
7 changes: 0 additions & 7 deletions src/color/color.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,20 +75,13 @@ export const SLIDER_SEGMENTS = 36;
export const PAD_SEGMENTS = 32;
/** How far a screen reader's step moves a slider or pad: 1/36 of the hue circle is 10°. */
export const COLOR_STEP = 1 / 36;
export const SLIDER_THUMB_SIZE = 28;
/** The slider's touch area, 44pt as iOS asks, however thin its track. */
export const SLIDER_HIT_HEIGHT = 44;
export const PAD_THUMB_SIZE = 22;
export const COLOR_A11Y_ACTIONS = [{ name: 'increment' }, { name: 'decrement' }];
/** The pad's second axis, for screen readers, which step one axis at a time. */
export const PAD_ACTION = { up: 'up', down: 'down' } as const;
export const COPIED_MS = 1500;
export const CHECKER_SIZE = 6;
export const CHECKER_COLUMNS = 80;

/** The thumb's ring and shadow, white on any colour as in the platforms' own colour sliders. */
export const THUMB_RING = '#FFFFFF';
export const THUMB_SHADOW = '0 1px 4px rgba(0, 0, 0, 0.3)';
export const PAD_WHITE = '#FFFFFF';
export const PAD_BLACK = '#000000';
export const CHECKER_LIGHT = '#FFFFFF';
Expand Down
18 changes: 10 additions & 8 deletions src/color/color.styles.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { StyleSheet } from 'react-native';

import { CHECKER_SIZE, PAD_THUMB_SIZE, SLIDER_HIT_HEIGHT, SLIDER_THUMB_SIZE, THUMB_RING, THUMB_SHADOW } from './color.constants';
import { KNOB_COLOR, KNOB_SHADOW, KNOB_SIZE, TOUCH_TARGET } from '../core/core.constants';
import { CHECKER_SIZE, PAD_THUMB_SIZE } from './color.constants';

/** The platforms' colour sliders ring the colour in white, so it shows on any colour. */
const thumb = {
position: 'absolute',
borderWidth: 3,
borderColor: THUMB_RING,
boxShadow: THUMB_SHADOW,
borderColor: KNOB_COLOR,
boxShadow: KNOB_SHADOW,
} as const;

export const styles = StyleSheet.create({
Expand All @@ -18,16 +20,16 @@ export const styles = StyleSheet.create({
swatches: { flexDirection: 'row', flexWrap: 'wrap' },
swatchRow: { flexDirection: 'row' },
swatch: { alignItems: 'center', justifyContent: 'center', borderWidth: StyleSheet.hairlineWidth },
slider: { height: SLIDER_HIT_HEIGHT, justifyContent: 'center' },
slider: { height: TOUCH_TARGET, justifyContent: 'center' },
sliderHeader: { flexDirection: 'row', justifyContent: 'space-between' },
rail: { justifyContent: 'center' },
track: { ...StyleSheet.absoluteFill, flexDirection: 'row', overflow: 'hidden' },
sliderThumb: {
...thumb,
width: SLIDER_THUMB_SIZE,
height: SLIDER_THUMB_SIZE,
borderRadius: SLIDER_THUMB_SIZE / 2,
marginLeft: -SLIDER_THUMB_SIZE / 2,
width: KNOB_SIZE,
height: KNOB_SIZE,
borderRadius: KNOB_SIZE / 2,
marginLeft: -KNOB_SIZE / 2,
},
pad: { overflow: 'hidden' },
layer: { ...StyleSheet.absoluteFill },
Expand Down
10 changes: 0 additions & 10 deletions src/color/color.utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import {
resolveColorLabels,
spectrumToHsv,
splitAlpha,
toFraction,
toSwatchCells,
withAlpha,
} from './color.utils';
Expand Down Expand Up @@ -116,15 +115,6 @@ describe('swatches', () => {
});
});

describe('toFraction', () => {
it('turns a touch into 0–1, clamped', () => {
expect(toFraction(50, 200)).toBe(0.25);
expect(toFraction(-10, 200)).toBe(0);
expect(toFraction(300, 200)).toBe(1);
expect(toFraction(10, 0)).toBe(0);
});
});

describe('platform features', () => {
it('has no clipboard or eyedropper of its own on native', () => {
expect(getDefaultCopy()).toBeNull();
Expand Down
5 changes: 1 addition & 4 deletions src/color/color.utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { IS_WEB } from '../core/core.constants';
import { clamp01 } from '../core/utils/track.utils';
import {
CHECK_ON_DARK,
CHECK_ON_LIGHT,
Expand All @@ -24,7 +25,6 @@ export const normalizeHex = (input: string) => {
return `#${full.endsWith(OPAQUE) && full.length === 8 ? full.slice(0, 6) : full}`;
};

export const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
const toHexByte = (n: number) =>
Math.round(clamp01(n) * 255)
.toString(16)
Expand Down Expand Up @@ -151,9 +151,6 @@ export const toSwatchCells = (colors: ColorOption[]): SwatchCell[] =>
...cell,
}));

/** 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);

export const percent = (value: number) => `${Math.round(value * 100)}%`;

export const degrees = (value: number) => `${Math.round(value * 360)}°`;
Expand Down
17 changes: 9 additions & 8 deletions src/color/components/ColorPad.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { View } from 'react-native';

import { KEY } from '../../core/core.constants';
import { A11Y_ACTION, ADJUSTABLE_ACTIONS, KEY } from '../../core/core.constants';
import { useTrackPan } from '../../core/hooks/useTrackPan';
import { keyboardProps } from '../../core/utils/keyboard.utils';
import { COLOR_A11Y_ACTIONS, COLOR_STEP, PAD_ACTION } from '../color.constants';
import { clamp01 } from '../../core/utils/track.utils';
import { COLOR_STEP, PAD_ACTION } from '../color.constants';
import { styles } from '../color.styles';
import type { ColorPadProps } from '../color.types';
import { clamp01 } from '../color.utils';
import { useTrackPan } from '../hooks/useTrackPan';

/**
* A square of colour to tap or drag across. Screen readers step it across with increment and decrement, and up and down
Expand All @@ -27,7 +27,7 @@ export const ColorPad = ({
slots,
testID,
}: ColorPadProps) => {
const pan = useTrackPan(onChange);
const pan = useTrackPan({ onMove: onChange });
const moveX = (by: number) => onChange(clamp01(x + by), y);
const moveY = (by: number) => onChange(x, clamp01(y + by));
return (
Expand All @@ -39,10 +39,11 @@ export const ColorPad = ({
accessibilityRole="adjustable"
accessibilityLabel={label}
accessibilityValue={{ text: valueText }}
accessibilityActions={[...COLOR_A11Y_ACTIONS, { name: PAD_ACTION.up, label: upLabel }, { name: PAD_ACTION.down, label: downLabel }]}
accessibilityActions={[...ADJUSTABLE_ACTIONS, { name: PAD_ACTION.up, label: upLabel }, { name: PAD_ACTION.down, label: downLabel }]}
onAccessibilityAction={({ nativeEvent: { actionName } }) => {
if (actionName === 'increment' || actionName === 'decrement') moveX(actionName === 'increment' ? COLOR_STEP : -COLOR_STEP);
else moveY(actionName === PAD_ACTION.up ? -COLOR_STEP : COLOR_STEP);
if (actionName === A11Y_ACTION.increment || actionName === A11Y_ACTION.decrement) {
moveX(actionName === A11Y_ACTION.increment ? COLOR_STEP : -COLOR_STEP);
} else moveY(actionName === PAD_ACTION.up ? -COLOR_STEP : COLOR_STEP);
}}
{...keyboardProps({
[KEY.right]: () => moveX(COLOR_STEP),
Expand Down
35 changes: 14 additions & 21 deletions src/color/components/ColorSlider.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import { Text, View } from 'react-native';

import { textStyle } from '../../core';
import { KEY } from '../../core/core.constants';
import { keyboardProps } from '../../core/utils/keyboard.utils';
import { COLOR_A11Y_ACTIONS, COLOR_DESIGN, COLOR_STEP, SLIDER_THUMB_SIZE } from '../color.constants';
import { KNOB_SIZE } from '../../core/core.constants';
import { useTrackPan } from '../../core/hooks/useTrackPan';
import { adjustableProps } from '../../core/utils/adjustable.utils';
import { clamp01 } from '../../core/utils/track.utils';
import { COLOR_DESIGN, COLOR_STEP } from '../color.constants';
import { styles } from '../color.styles';
import type { ColorSliderProps } from '../color.types';
import { toSlices } from '../color.utils';
import { useTrackPan } from '../hooks/useTrackPan';
import { Checkerboard } from './Checkerboard';

const INSET = SLIDER_THUMB_SIZE / 2;
const INSET = KNOB_SIZE / 2;

/** A slider over its own gradient, drawn as slices of solid colour; 44pt to touch however thin the track. */
export const ColorSlider = ({
Expand All @@ -27,8 +28,7 @@ export const ColorSlider = ({
slots,
testID,
}: ColorSliderProps) => {
const pan = useTrackPan((x) => onChange(x), INSET);
const step = (by: number) => onChange(Math.min(1, Math.max(0, value + by)));
const pan = useTrackPan({ onMove: (x) => onChange(x), insetX: INSET });
const { trackHeight } = COLOR_DESIGN[theme.design];
const caption = [textStyle(theme, 'sm'), { color: theme.colors.textSecondary }, slots?.sliderLabel];
return (
Expand All @@ -43,19 +43,12 @@ export const ColorSlider = ({
style={[styles.slider, slots?.slider]}
onLayout={pan.onLayout}
{...pan.panHandlers}
accessible
accessibilityRole="adjustable"
accessibilityLabel={label}
accessibilityValue={{ text: valueText }}
accessibilityActions={COLOR_A11Y_ACTIONS}
onAccessibilityAction={(e) => step(e.nativeEvent.actionName === 'increment' ? COLOR_STEP : -COLOR_STEP)}
{...keyboardProps({
[KEY.right]: () => step(COLOR_STEP),
[KEY.up]: () => step(COLOR_STEP),
[KEY.left]: () => step(-COLOR_STEP),
[KEY.down]: () => step(-COLOR_STEP),
[KEY.home]: () => onChange(0),
[KEY.end]: () => onChange(1),
{...adjustableProps({
label,
valueText,
onStep: (direction) => onChange(clamp01(value + direction * COLOR_STEP)),
onMin: () => onChange(0),
onMax: () => onChange(1),
})}
testID={testID}
>
Expand All @@ -69,7 +62,7 @@ export const ColorSlider = ({
<View
style={[
styles.sliderThumb,
{ left: `${value * 100}%`, top: (trackHeight - SLIDER_THUMB_SIZE) / 2, backgroundColor: thumbColor },
{ left: `${value * 100}%`, top: (trackHeight - KNOB_SIZE) / 2, backgroundColor: thumbColor },
slots?.sliderThumb,
]}
/>
Expand Down
5 changes: 3 additions & 2 deletions src/color/components/IosGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { View } from 'react-native';

import type { PickerTheme } from '../../core';
import { Touchable } from '../../core/components/Touchable';
import { IOS_GRID_CELL_HEIGHT, THUMB_RING } from '../color.constants';
import { KNOB_COLOR } from '../../core/core.constants';
import { IOS_GRID_CELL_HEIGHT } from '../color.constants';
import { styles } from '../color.styles';
import { getIosGridColors } from '../color.utils';

Expand All @@ -27,7 +28,7 @@ export const IosGrid = ({ selected, onSelect, theme }: { selected: string; onSel
style={[
styles.gridCell,
{ height: IOS_GRID_CELL_HEIGHT, backgroundColor: color },
chosen && [styles.gridCellSelected, { borderColor: THUMB_RING }],
chosen && [styles.gridCellSelected, { borderColor: KNOB_COLOR }],
]}
/>
);
Expand Down
43 changes: 0 additions & 43 deletions src/color/hooks/useTrackPan.ts

This file was deleted.

20 changes: 20 additions & 0 deletions src/core/core.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,24 @@ export const KEY = {
right: 'ArrowRight',
home: 'Home',
end: 'End',
pageUp: 'PageUp',
pageDown: 'PageDown',
} as const;

/** The screen-reader actions of an adjustable control, a slider or a wheel. */
export const A11Y_ACTION = {
increment: 'increment',
decrement: 'decrement',
} as const;
export const ADJUSTABLE_ACTIONS = [{ name: A11Y_ACTION.increment }, { name: A11Y_ACTION.decrement }];

/** A slider's touch area, 44pt as iOS asks, however thin its track. */
export const TOUCH_TARGET = 44;
/** iOS's slider knob: white on any colour, with a soft shadow. */
export const KNOB_SIZE = 28;
export const KNOB_COLOR = '#FFFFFF';
export const KNOB_SHADOW = '0 1px 4px rgba(0, 0, 0, 0.3)';

export const DIRECTION = {
up: 'up',
down: 'down',
Expand All @@ -117,3 +133,7 @@ export const ARROW_DIRECTION: Record<string, ValueOf<typeof DIRECTION>> = {
/** What arrow-key focus moves to: what Tab stops at, text fields included. */
export const FOCUS_TARGETS = '[tabindex="0"], input:not([disabled]), textarea:not([disabled])';
export const TEXT_FIELD_TAGS = ['INPUT', 'TEXTAREA'];
/** How far (px) a box may overlap the one focus leaves and still count as lying that way. */
export const EDGE_TOLERANCE = 1;
/** A step across counts double a step along, so moving down keeps to the column and moving right to the row. */
export const CROSS_WEIGHT = 2;
Loading