diff --git a/.gitignore b/.gitignore index ad2280a..859d5f6 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 81d6e79..8dfc168 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![npm](https://img.shields.io/npm/v/@elik56a/react-native-pickers)](https://www.npmjs.com/package/@elik56a/react-native-pickers) [![downloads](https://img.shields.io/npm/dw/@elik56a/react-native-pickers)](https://www.npmjs.com/package/@elik56a/react-native-pickers) [![bundle size](https://img.shields.io/bundlephobia/minzip/@elik56a/react-native-pickers)](https://bundlephobia.com/package/@elik56a/react-native-pickers) [![CI](https://github.com/elik56a/react-native-pickers/actions/workflows/ci.yml/badge.svg)](https://github.com/elik56a/react-native-pickers/actions/workflows/ci.yml) [![license](https://img.shields.io/npm/l/@elik56a/react-native-pickers)](LICENSE) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/elik56a/react-native-pickers/badge)](https://scorecard.dev/viewer/?uri=github.com/elik56a/react-native-pickers) -**Every picker a React Native app needs, in one package with zero native code.** Wheel, time, duration, date, month, week strip, date range, date & time, select, country, phone number, time zone, segmented control, emoji and color pickers, a slider 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.

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 @@ -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 | @@ -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. diff --git a/context7.json b/context7.json index f9f31d4..0a88334 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, 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.", diff --git a/package.json b/package.json index 06b4138..3d906bf 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), 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 ", "homepage": "https://react-native-pickers.dev/", @@ -76,6 +76,8 @@ "range-slider", "otp-input", "otp", + "rating", + "star-rating", "verification-code", "colour-picker", "hex-color-picker", diff --git a/src/core/utils/track.utils.test.ts b/src/core/utils/track.utils.test.ts index 67d8eac..531ee57 100644 --- a/src/core/utils/track.utils.test.ts +++ b/src/core/utils/track.utils.test.ts @@ -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', () => { @@ -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); + }); }); diff --git a/src/core/utils/track.utils.ts b/src/core/utils/track.utils.ts index ba88fa1..6b0464e 100644 --- a/src/core/utils/track.utils.ts +++ b/src/core/utils/track.utils.ts @@ -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); diff --git a/src/index.ts b/src/index.ts index 1496544..87f69d2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; diff --git a/src/rating/Rating.test.tsx b/src/rating/Rating.test.tsx new file mode 100644 index 0000000..1e10120 --- /dev/null +++ b/src/rating/Rating.test.tsx @@ -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(); + 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(, 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(); + 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(); + 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(); + 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( {filled ? '♥' : '♡'}} />); + 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(); + await rerender(); + expect(screen.getByRole('adjustable').props.accessibilityValue).toEqual({ text: '4 of 5' }); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/src/rating/Rating.tsx b/src/rating/Rating.tsx new file mode 100644 index 0000000..efcd669 --- /dev/null +++ b/src/rating/Rating.tsx @@ -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 ( + + {rating.items.map((item) => ( + + ))} + + ); +}; diff --git a/src/rating/components/RatingIcon.tsx b/src/rating/components/RatingIcon.tsx new file mode 100644 index 0000000..0812acc --- /dev/null +++ b/src/rating/components/RatingIcon.tsx @@ -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) + ) : ( + + {STAR_GLYPH} + + ); diff --git a/src/rating/components/RatingItem.tsx b/src/rating/components/RatingItem.tsx new file mode 100644 index 0000000..b0d607e --- /dev/null +++ b/src/rating/components/RatingItem.tsx @@ -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 ( + + + {item.fill > 0 ? ( + + + + + + ) : null} + + ); +}; diff --git a/src/rating/hooks/useRating.ts b/src/rating/hooks/useRating.ts new file mode 100644 index 0000000..9b03d1d --- /dev/null +++ b/src/rating/hooks/useRating.ts @@ -0,0 +1,112 @@ +import { useRef, useState } from 'react'; +import { I18nManager, type LayoutChangeEvent, type PointerEvent } from 'react-native'; + +import { useControllableState } from '../../core/hooks/useControllableState'; +import { useTrackPan } from '../../core/hooks/useTrackPan'; +import type { PickerTheme } from '../../core/theme/theme.types'; +import { adjustableProps } from '../../core/utils/adjustable.utils'; +import { toFraction } from '../../core/utils/track.utils'; +import { DEFAULT_RATING_LABEL, DEFAULT_RATING_MAX, DEFAULT_RATING_SIZE, DEFAULT_RATING_STEP } from '../rating.constants'; +import type { RatingProps, RatingState } from '../rating.types'; +import { formatRating, getFills, isOnStars, ratingAt, stepRating } from '../rating.utils'; + +/** + * The rating and what the row shows: a drag previews its value and commits on release, and on the web the pointer + * previews the value a click would pick. + */ +export const useRating = (props: RatingProps, theme: PickerTheme): RatingState => { + const { + max = DEFAULT_RATING_MAX, + step = DEFAULT_RATING_STEP, + size = DEFAULT_RATING_SIZE, + readOnly = false, + disabled = false, + formatValue = formatRating, + accessibilityLabel = DEFAULT_RATING_LABEL, + } = props; + const gap = theme.spacing.xs; + const geometry = { + size, + gap, + max, + step, + }; + const interactive = !readOnly && !disabled; + const [value, commit] = useControllableState(props.value, props.defaultValue ?? 0, props.onChange); + const [drag, setDrag] = useState(null); + const [hover, setHover] = useState(null); + // The pan reads these between renders, so a fast drag never commits a stale value. + const live = useRef(null); + const grabbed = useRef(false); + const width = useRef(0); + + const set = (next: number) => { + if (next !== value) commit(next); + }; + // Touches come in screen coordinates; the stars run from the row's start, which is the right edge in RTL. + const xAt = (fraction: number) => (I18nManager.isRTL ? 1 - fraction : fraction) * width.current; + const moveTo = (fraction: number) => { + if (!grabbed.current) return; + live.current = ratingAt(xAt(fraction), geometry); + setDrag(live.current); + }; + const pan = useTrackPan({ + // A row wider than its stars ignores touches in the empty space after them; a drag that starts on a star can still run past it. + onStart: (fraction) => { + grabbed.current = isOnStars(xAt(fraction), geometry); + moveTo(fraction); + }, + onMove: moveTo, + onEnd: () => { + const next = live.current; + live.current = null; + grabbed.current = false; + setDrag(null); + if (next !== null) set(next); + }, + }); + const hoverAt = (offsetX: number) => { + const x = xAt(toFraction(offsetX, width.current)); + setHover(isOnStars(x, geometry) ? ratingAt(x, geometry) : null); + }; + + const valueText = formatValue(value, max); + const shown = drag ?? hover ?? value; + + return { + items: getFills(shown, max).map((fill, index) => ({ + key: String(index), + index, + fill, + })), + gap, + interactive, + a11y: readOnly + ? { + accessible: true, + accessibilityRole: 'text', + accessibilityLabel, + accessibilityValue: { text: valueText }, + } + : adjustableProps({ + label: accessibilityLabel, + valueText, + disabled, + onStep: (direction) => set(stepRating(value, direction, step, max)), + onMin: () => set(0), + onMax: () => set(max), + }), + panHandlers: interactive ? pan.panHandlers : undefined, + // offsetX is on the row, as the stars let the pointer through. + hoverHandlers: interactive + ? { + onPointerMove: (e: PointerEvent) => hoverAt(e.nativeEvent.offsetX), + onPointerLeave: () => setHover(null), + } + : undefined, + onLayout: (e: LayoutChangeEvent) => { + pan.onLayout(e); + width.current = e.nativeEvent.layout.width; + }, + }; +}; diff --git a/src/rating/index.ts b/src/rating/index.ts new file mode 100644 index 0000000..441f40c --- /dev/null +++ b/src/rating/index.ts @@ -0,0 +1,2 @@ +export { Rating } from './Rating'; +export type { RatingIconInfo, RatingProps, RatingSlot } from './rating.types'; diff --git a/src/rating/rating.constants.ts b/src/rating/rating.constants.ts new file mode 100644 index 0000000..e1c4026 --- /dev/null +++ b/src/rating/rating.constants.ts @@ -0,0 +1,10 @@ +export const DEFAULT_RATING_MAX = 5; +export const DEFAULT_RATING_STEP = 1; +export const DEFAULT_RATING_SIZE = 32; +export const DEFAULT_RATING_LABEL = 'Rating'; +/** Stars are gold everywhere, whatever the theme's primary colour. */ +export const RATING_COLOR = '#FFB400'; +export const STAR_GLYPH = '★'; +export const RATING_DISABLED_OPACITY = 0.38; +/** Decimals kept when dividing by the step, so float noise (2.0000001) never rounds up a star. */ +export const STEP_PRECISION = 6; diff --git a/src/rating/rating.styles.ts b/src/rating/rating.styles.ts new file mode 100644 index 0000000..3fdaa8b --- /dev/null +++ b/src/rating/rating.styles.ts @@ -0,0 +1,12 @@ +import { StyleSheet } from 'react-native'; + +import { TOUCH_TARGET } from '../core/core.constants'; +import { RATING_DISABLED_OPACITY } from './rating.constants'; + +export const styles = StyleSheet.create({ + row: { flexDirection: 'row', alignItems: 'center', minHeight: TOUCH_TARGET }, + pointer: { cursor: 'pointer' }, + disabled: { opacity: RATING_DISABLED_OPACITY }, + fill: { position: 'absolute', top: 0, bottom: 0, start: 0, overflow: 'hidden' }, + glyph: { textAlign: 'center', includeFontPadding: false }, +}); diff --git a/src/rating/rating.types.ts b/src/rating/rating.types.ts new file mode 100644 index 0000000..4883747 --- /dev/null +++ b/src/rating/rating.types.ts @@ -0,0 +1,90 @@ +import type React from 'react'; +import type { GestureResponderHandlers, LayoutChangeEvent, PointerEvent, StyleProp, ViewProps, ViewStyle } from 'react-native'; + +import type { SlotStyles, ThemableProps } from '../core'; + +/** What a custom icon is drawn with. Each star draws it twice: empty, then filled and cut to the rating. */ +export interface RatingIconInfo { + index: number; + filled: boolean; + color: string; + size: number; +} + +export type RatingSlot = 'container' | 'item' | 'icon'; + +export interface RatingProps extends ThemableProps { + value?: number; + /** Default: 0, no stars */ + defaultValue?: number; + /** On a tap, when a drag ends on a new value, or on a key or screen-reader step */ + onChange?: (value: number) => void; + /** How many stars. Default: 5 */ + max?: number; + /** The value moves in steps this big: 0.5 for half stars. Default: 1 */ + step?: number; + /** Shows the value, any fraction such as 4.3, and takes no touches or keys. Default: false */ + readOnly?: boolean; + disabled?: boolean; + /** Each star's width and height. Default: 32 */ + size?: number; + /** The filled stars. Default: gold */ + color?: string; + /** The empty stars. Default: the theme's border colour */ + emptyColor?: string; + /** Your own icon instead of ★: a heart, a thumb, an image */ + renderIcon?: (info: RatingIconInfo) => React.ReactNode; + /** The value as screen readers hear it. Default: '3 of 5' */ + formatValue?: (value: number, max: number) => string; + /** What screen readers call the rating. Default: 'Rating' */ + accessibilityLabel?: string; + style?: StyleProp; + styles?: SlotStyles; + /** On the row; star `i` gets `${testID}-star-${i}` */ + testID?: string; +} + +/** Where a star sits in the row, for turning a touch into a value. */ +export interface RatingGeometry { + size: number; + gap: number; + max: number; + step: number; +} + +export interface RatingItemState { + key: string; + index: number; + /** How much of the star is filled, 0–1 */ + fill: number; +} + +/** What `useRating` hands the row. Named so the build can emit the hook returning it. */ +export interface RatingState { + items: RatingItemState[]; + gap: number; + interactive: boolean; + a11y: ViewProps; + panHandlers?: GestureResponderHandlers; + hoverHandlers?: { + onPointerMove: (e: PointerEvent) => void; + onPointerLeave: () => void; + }; + onLayout: (e: LayoutChangeEvent) => void; +} + +export interface RatingIconProps { + info: RatingIconInfo; + renderIcon?: RatingProps['renderIcon']; + slots?: SlotStyles; +} + +export interface RatingItemProps { + item: RatingItemState; + size: number; + color: string; + emptyColor: string; + renderIcon?: RatingProps['renderIcon']; + slots?: SlotStyles; + testID?: string; +} diff --git a/src/rating/rating.utils.test.ts b/src/rating/rating.utils.test.ts new file mode 100644 index 0000000..20cc2d3 --- /dev/null +++ b/src/rating/rating.utils.test.ts @@ -0,0 +1,63 @@ +import { formatRating, getFills, isOnStars, ratingAt, stepRating } from './rating.utils'; + +const stars = { + size: 30, + gap: 10, + max: 5, + step: 1, +}; + +describe('ratingAt', () => { + it('picks the star under the touch, and at least one', () => { + expect(ratingAt(0, stars)).toBe(1); + expect(ratingAt(15, stars)).toBe(1); + expect(ratingAt(45, stars)).toBe(2); + expect(ratingAt(500, stars)).toBe(5); + }); + + it('counts the gap after a star as that star', () => { + expect(ratingAt(35, stars)).toBe(1); + }); + + it('fills half a star with half steps', () => { + const halves = { ...stars, step: 0.5 }; + expect(ratingAt(50, halves)).toBe(1.5); + expect(ratingAt(65, halves)).toBe(2); + expect(ratingAt(2, halves)).toBe(0.5); + }); +}); + +describe('isOnStars', () => { + it('ends at the last star, without the gap after it', () => { + expect(isOnStars(190, stars)).toBe(true); + expect(isOnStars(191, stars)).toBe(false); + }); +}); + +describe('stepRating', () => { + it('moves a step and stays between 0 and max', () => { + expect(stepRating(3, 1, 1, 5)).toBe(4); + expect(stepRating(5, 1, 1, 5)).toBe(5); + expect(stepRating(0, -1, 1, 5)).toBe(0); + expect(stepRating(2.5, -1, 0.5, 5)).toBe(2); + }); + + it('lands on the step grid from a value between steps', () => { + expect(stepRating(3.3, 1, 1, 5)).toBe(4); + expect(stepRating(3.3, -1, 1, 5)).toBe(3); + }); +}); + +describe('getFills', () => { + it('fills each star up to the value', () => { + expect(getFills(3.5, 5)).toEqual([1, 1, 1, 0.5, 0]); + expect(getFills(0, 3)).toEqual([0, 0, 0]); + }); +}); + +describe('formatRating', () => { + it('reads the value out of max', () => { + expect(formatRating(4, 5)).toBe('4 of 5'); + expect(formatRating(4.333, 5)).toBe('4.33 of 5'); + }); +}); diff --git a/src/rating/rating.utils.ts b/src/rating/rating.utils.ts new file mode 100644 index 0000000..2fa5cf5 --- /dev/null +++ b/src/rating/rating.utils.ts @@ -0,0 +1,28 @@ +import { clamp, clamp01, roundTo, stepDecimals } from '../core/utils/track.utils'; +import { STEP_PRECISION } from './rating.constants'; +import type { RatingGeometry } from './rating.types'; + +const stepsIn = (value: number, step: number) => roundTo(value / step, STEP_PRECISION); + +/** The rating a touch `x` px from the row's start picks: the star under it, filled up to the next step, and at least one step. */ +export const ratingAt = (x: number, { size, gap, max, step }: RatingGeometry) => { + const pitch = size + gap; + const index = Math.floor(x / pitch); + const reached = index + clamp01((x - index * pitch) / size); + return clamp(roundTo(Math.ceil(stepsIn(reached, step)) * step, stepDecimals(step)), step, max); +}; + +/** Whether a touch `x` px from the row's start is on the stars, not in the empty row after them. */ +export const isOnStars = (x: number, { size, gap, max }: RatingGeometry) => x <= max * (size + gap) - gap; + +/** One step up or down from `value`, landing on the step grid even when the value is between steps. */ +export const stepRating = (value: number, direction: 1 | -1, step: number, max: number) => { + const steps = stepsIn(value, step); + const next = (direction > 0 ? Math.floor(steps) + 1 : Math.ceil(steps) - 1) * step; + return clamp(roundTo(next, stepDecimals(step)), 0, max); +}; + +/** How much of each star `value` fills: 3.5 of 5 → 1, 1, 1, 0.5, 0. */ +export const getFills = (value: number, max: number) => Array.from({ length: max }, (_, i) => clamp01(value - i)); + +export const formatRating = (value: number, max: number) => `${roundTo(value, 2)} of ${max}`; diff --git a/src/slider/slider.utils.ts b/src/slider/slider.utils.ts index e337965..51eff4e 100644 --- a/src/slider/slider.utils.ts +++ b/src/slider/slider.utils.ts @@ -1,4 +1,4 @@ -import { clamp } from '../core/utils/track.utils'; +import { clamp, roundTo, stepDecimals } from '../core/utils/track.utils'; import { CONTINUOUS_DECIMALS, CONTINUOUS_STEP_FRACTION, HALO_LEVEL, MIN_MARK_SPACING } from './slider.constants'; import type { HaloLevel, @@ -11,15 +11,12 @@ import type { TrackSegment, } from './slider.types'; -const decimalsOf = (step: number) => (step > 0 ? (String(step).split('.')[1]?.length ?? 0) : CONTINUOUS_DECIMALS); - -/** Rounds away float noise, so 0.1 + 0.2 lands on 0.3. */ -const round = (n: number, decimals: number) => Number(n.toFixed(decimals)); +const decimalsOf = (step: number) => (step > 0 ? stepDecimals(step) : CONTINUOUS_DECIMALS); /** The value in range, on the nearest step counted from `min`. */ export const snapValue = (value: number, min: number, max: number, step: number) => { const snapped = step > 0 ? min + Math.round((value - min) / step) * step : value; - return clamp(round(snapped, decimalsOf(step)), min, max); + return clamp(roundTo(snapped, decimalsOf(step)), min, max); }; export const valueToFraction = (value: number, min: number, max: number) => (max > min ? clamp((value - min) / (max - min), 0, 1) : 0); @@ -54,7 +51,7 @@ export const sameValues = (a: readonly number[], b: readonly number[]) => a.leng export const toThumbValues = (value: number | SliderRange, min: number, max: number) => (Array.isArray(value) ? [...value].sort((a, b) => a - b) : [value]).map((v) => clamp(v, min, max)); -export const formatSliderValue = (value: number, step: number) => String(round(value, decimalsOf(step))); +export const formatSliderValue = (value: number, step: number) => String(roundTo(value, decimalsOf(step))); /** * The track cut at the thumbs, `cut` px either side of each (Material's gap). A single slider is active from its start diff --git a/website/docs/api/reference.mdx b/website/docs/api/reference.mdx index 81ceb21..aa6933d 100644 --- a/website/docs/api/reference.mdx +++ b/website/docs/api/reference.mdx @@ -31,6 +31,7 @@ Every export from `@elik56a/react-native-pickers`, grouped. Each name links to i | [`ColorPicker`](../components/color-picker) | iOS's tabs, Google's swatch circles or the browser's spectrum; hex with opacity | | [`Slider`](../components/slider) | iOS's knob, Material 3's handle or a range input; one value or a range | | [`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 | ## Sheets @@ -179,6 +180,7 @@ interface ThemableProps { theme?: DeepPartial } | `min` / `max` / `step` | `0` / `100` / `1` | Slider | | `valueLabel` | `'drag'` Material · `'never'` iOS, web | Slider | | `length` / `inputMode` | `6` / `'numeric'` | OtpField | +| `max` / `step` / `size` | `5` / `1` / `32` | Rating | | `display` / `timeDisplay` | `'wheel'` / `'wheel'` iOS · `'calendar'` / `'input'` Material · `'calendar'` / `'list'` web | DateTimePicker | | `timePosition` | `'beside'` web · `'below'` other | DateTimePicker | | `dayHeight` | `44` | Calendar | diff --git a/website/docs/components/rating.mdx b/website/docs/components/rating.mdx new file mode 100644 index 0000000..7685cff --- /dev/null +++ b/website/docs/components/rating.mdx @@ -0,0 +1,131 @@ +--- +title: React Native Star Rating +description: "React Native star rating: tap or drag across the stars, whole or half steps, a read-only score with any fraction, your own icon (hearts, thumbs), keyboard and screen-reader steps, RTL. Pure JS, no SVG, works in Expo Go." +--- + +import PropPlayground from '@site/src/components/PropPlayground'; +import { PLAYGROUNDS } from '@site/src/catalog/playgrounds'; + +# Rating + +`Rating` is a React Native star rating: tap a star, or drag across the row and let go. Use it to ask for a review, or with `readOnly` to show a score such as 4.3. + +The stars are a ★ character, so there is no SVG or image to install; pass `renderIcon` for hearts, thumbs or your own icon. On the web the pointer previews the rating a click would pick. + +```tsx +import { Rating } from '@elik56a/react-native-pickers'; +``` + +```tsx live noplatforms +import { useState } from 'react'; +import { Text, View } from 'react-native'; +import { Rating } from '@elik56a/react-native-pickers'; + +export default function Example() { + const [stars, setStars] = useState(0); + return ( + + + {stars ? `${stars} of 5` : 'Tap a star'} + + ); +} +``` + +## Try the props + + + +## Half stars + +`step={0.5}` picks half stars: the left half of a star gives .5, the right half the whole star. A drag shows the value as it moves and calls `onChange` once, when the finger lifts. + +```tsx live noplatforms +import { useState } from 'react'; +import { Rating } from '@elik56a/react-native-pickers'; + +export default function Example() { + const [stars, setStars] = useState(3.5); + return ; +} +``` + +## Showing a score + +`readOnly` shows any value, filling part of a star for a fraction, and takes no touches. Screen readers hear *Rating, 4.3 of 5*. + +```tsx live noplatforms +import { Text, View } from 'react-native'; +import { Rating } from '@elik56a/react-native-pickers'; + +export default function Example() { + return ( + + + 4.3 · 1,284 reviews + + ); +} +``` + +## Your own icon + +`renderIcon` draws each icon twice, empty and filled, and the filled one is cut to the rating, so half steps and fractions work with any icon. It gets `{ index, filled, color, size }`. + +```tsx live noplatforms +import { Text } from 'react-native'; +import { Rating } from '@elik56a/react-native-pickers'; + +export default function Example() { + return ( + ♥} + /> + ); +} +``` + +Colour your icon with the `color` it receives: the filled stars get `color` (gold by default) and the empty ones `emptyColor` (the theme's border colour). + +## Props + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `value` | `number` | — | The rating (controlled). | +| `defaultValue` | `number` | `0` | Starting rating when `value` isn't set. | +| `onChange` | `(value: number) => void` | — | On a tap, when a drag ends on a new value, or on a key or screen-reader step. | +| `max` | `number` | `5` | How many stars. | +| `step` | `number` | `1` | The value moves in steps this big: `0.5` for half stars. | +| `readOnly` | `boolean` | `false` | Shows the value, any fraction, and takes no touches or keys. | +| `disabled` | `boolean` | `false` | Dims the stars and ignores touches and keys. | +| `size` | `number` | `32` | Each star's width and height. | +| `color` | `string` | gold, `#FFB400` | The filled stars. | +| `emptyColor` | `string` | theme `border` | The empty stars. | +| `renderIcon` | `(info: RatingIconInfo) => ReactNode` | ★ | Your own icon, drawn empty and filled. | +| `formatValue` | `(value: number, max: number) => string` | `'3 of 5'` | The value as screen readers hear it. | +| `accessibilityLabel` | `string` | `'Rating'` | What screen readers call the rating. | +| `theme` / `design` | | | See [Theming](../guides/theming) and [Designs](../guides/designs). | +| `style` | `ViewStyle` | — | The row. It stretches with its parent like any `View`, the stars at its start; taps after the last star do nothing. Centre it with `alignSelf: 'center'`. | +| `styles` | `{ container, item, icon }` | — | [Slot styles](../guides/styling): the row, each star's box, and the ★ text. | +| `testID` | `string` | — | On the row; star `i` gets `${testID}-star-${i}`. | + +```ts +interface RatingIconInfo { + index: number; // 0 for the first star + filled: boolean; + color: string; // color or emptyColor + size: number; +} +``` + +## Accessibility + +The row is one adjustable control, as a slider is: swipe up or down with VoiceOver or TalkBack to change the rating by a step. On the web, arrow keys step it, and Home and End go to 0 and to `max`. With `readOnly`, it's text: the label and the score. + +## Related + +- [Slider](./slider): any number in a range +- [Migrate from react-native-ratings](../resources/migration/ratings) or [react-native-star-rating-widget](../resources/migration/star-rating-widget) diff --git a/website/docs/guides/designs.mdx b/website/docs/guides/designs.mdx index da6ef43..f550cb0 100644 --- a/website/docs/guides/designs.mdx +++ b/website/docs/guides/designs.mdx @@ -72,6 +72,7 @@ export default function Example() { | Colour | Grid, Spectrum and Sliders tabs, swatches in a row to scroll | swatch circles, then **Custom** for a saturation square and hue slider | a saturation square, eyedropper, hue slider and hex code, then swatches | | 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 | | 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 b8c5dd4..238bd4f 100644 --- a/website/docs/guides/styling.mdx +++ b/website/docs/guides/styling.mdx @@ -56,6 +56,7 @@ type SlotStyles = Partial>>; | [ColorPicker](../components/color-picker#props) | `container` `swatch` `pad` `slider` `sliderThumb` `sliderLabel` `hexInput` `preview` | | [Slider](../components/slider#styling) | `container` `track` `activeTrack` `thumb` `mark` `markLabel` `valueLabel` `valueLabelText` | | [OtpField](../components/otp-field#props) | `container` `cell` `focusedCell` `filledCell` `cellText` `caret` | +| [Rating](../components/rating#props) | `container` `item` `icon` | | [SelectList](../components/select-list#slots) | `container` `search` `searchInput` `list` `groupHeader` `groupHeaderText` `option` `selectedOption` `optionLabel` `selectedOptionLabel` `optionDescription` `check` `empty` `emptyText` `loading` `loadingText` | | [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` | diff --git a/website/docs/guides/typescript.mdx b/website/docs/guides/typescript.mdx index 6f444af..7f02ba7 100644 --- a/website/docs/guides/typescript.mdx +++ b/website/docs/guides/typescript.mdx @@ -63,6 +63,8 @@ import type { ColorPickerProps, ColorPickerSlot, ColorOption, ColorDisplay, ColorLabels, // slider SliderProps, SingleSliderProps, RangeSliderProps, SliderRange, SliderSlot, SliderMarkInput, SliderValueLabel, + // rating + RatingProps, RatingSlot, RatingIconInfo, // otp OtpFieldProps, OtpFieldSlot, OtpInputMode, // select diff --git a/website/docs/intro.mdx b/website/docs/intro.mdx index 4446804..74c6128 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 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 and form fields. Looks native on iOS, Android and the web, with zero native code. Fully themeable." --- # React Native Pickers @@ -93,6 +93,7 @@ Edit the code and the result updates instantly. Each example is a complete file | [`ColorPicker`](./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`](./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`](./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 | | [`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 b88f978..4b02aef 100644 --- a/website/docs/resources/ai.mdx +++ b/website/docs/resources/ai.mdx @@ -90,6 +90,9 @@ Slider value: number onChange(value) (on release, or a key/screen-r OtpField value: string (the code so far) onChange(code) (typing, deleting, pasting) onComplete(code) (last cell filled) length=6 inputMode 'numeric'|'text' (digits on a number pad | letters and digits) secureTextEntry (dots) autoFocus disabled accessibilityLabel='Verification code' one hidden TextInput over the cells: paste and SMS autofill work (oneTimeCode on iOS/web, sms-otp on Android); a pasted message keeps only the code chars; no ref: clear with value='', blur with Keyboard.dismiss() in onComplete cells look like the design's text fields; slots container cell focusedCell filledCell cellText caret +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 Sheets (draft; commit on Done): DatePickerSheet visible onClose value onConfirm + all calendar props + mode + display + confirmOnSelect diff --git a/website/docs/resources/migration/index.mdx b/website/docs/resources/migration/index.mdx index 6518140..e4443be 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 and OTP inputs 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 and star ratings to React Native Pickers." --- # Migration @@ -29,5 +29,7 @@ Compared with native pickers, your users keep a native look: the iOS design on i | [`@ptomasroos/react-native-multi-slider`](./multi-slider.mdx) | `Slider` with `range` | | [`react-native-otp-entry`](./otp-entry.mdx) | `OtpField` | | [`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` | 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/ratings.mdx b/website/docs/resources/migration/ratings.mdx new file mode 100644 index 0000000..d3e5e66 --- /dev/null +++ b/website/docs/resources/migration/ratings.mdx @@ -0,0 +1,42 @@ +--- +title: Migrate from react-native-ratings +sidebar_label: 'ratings' +description: "Switch from react-native-ratings to React Native Pickers: Rating with tap and drag, half steps, read-only fractions, your own icon instead of images, and keyboard and screen-reader support. No images or native code." +--- + +# Migrate from `react-native-ratings` + +```diff +- import { AirbnbRating, Rating } from 'react-native-ratings'; ++ import { Rating } from '@elik56a/react-native-pickers'; + +- ++ +``` + +| react-native-ratings | React Native Pickers | +| --- | --- | +| `count` / `ratingCount` | `max` (default `5`) | +| `defaultRating` / `startingValue` | `defaultValue`, or `value` to control it | +| `onFinishRating` | `onChange`, once on a tap or the end of a drag | +| `onStartRating` / `onSwipeRating` | — | +| `size` / `imageSize` | `size` (default `32`) | +| `selectedColor` / `ratingColor` | `color` | +| `unSelectedColor` / `ratingBackgroundColor` | `emptyColor` | +| `jumpValue={0.5}` / `fractions` | `step={0.5}` | +| `minValue` | taps pick at least one step; keys can go to `0` | +| `isDisabled` / `readonly` | `readOnly` to show a score, or `disabled` to dim it | +| `type="heart"` / `starImage` / `ratingImage` | `renderIcon={({ color, size }) => …}` | +| `reviews` + `showRating` | render your own label from `value` | +| `starContainerStyle` / `style` | `styles={{ container, item }}` / `style` | + +There are no images to bundle: stars are text, and `renderIcon` takes any icon you already use. [`Rating`](../../components/rating) is adjustable for screen readers and steps with arrow keys on the web. + +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/star-rating-widget.mdx b/website/docs/resources/migration/star-rating-widget.mdx new file mode 100644 index 0000000..5f74abf --- /dev/null +++ b/website/docs/resources/migration/star-rating-widget.mdx @@ -0,0 +1,38 @@ +--- +title: Migrate from react-native-star-rating-widget +sidebar_label: 'star-rating-widget' +description: "Switch from react-native-star-rating-widget to React Native Pickers: Rating with tap and drag, half steps, read-only fractions, your own icon, and iOS, Material and web theming. No SVG dependency." +--- + +# Migrate from `react-native-star-rating-widget` + +```diff +- import StarRating, { StarRatingDisplay } from 'react-native-star-rating-widget'; ++ import { Rating } from '@elik56a/react-native-pickers'; + +- ++ + +- ++ +``` + +| star-rating-widget | React Native Pickers | +| --- | --- | +| `rating` / `onChange` | `value` / `onChange`, once on a tap or the end of a drag | +| `StarRatingDisplay` | `readOnly` | +| `maxStars` | `max` | +| `starSize` | `size` | +| `color` / `emptyColor` | `color` / `emptyColor` | +| `step="half"` (default) | `step={0.5}`; the default here is `1` | +| `step="quarter"` | `step={0.25}` | +| `enableSwiping` | always on: tap or drag | +| `onRatingStart` / `onRatingEnd` | `onChange` fires once, when the drag ends | +| `StarIconComponent` | `renderIcon={({ index, filled, color, size }) => …}` | +| `starStyle` / `starContainerStyle` | `styles={{ item }}` / `styles={{ container }}` | +| `accessibilityLabel` | `accessibilityLabel`, and `formatValue` for the value | +| `animationConfig` | — | + +Mind the default step: half stars here need `step={0.5}`. [`Rating`](../../components/rating) has no SVG dependency, so there's nothing else to install. + +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/docusaurus.config.ts b/website/docusaurus.config.ts index 1927b73..556d198 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -38,17 +38,13 @@ 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 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 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'); // Written by scripts/prepare.mts from the git tags, newest first. The line npm currently serves gets its exact version. // Only the latest release is indexed by search engines; Next and older lines would compete with it as duplicates. const releasedLines: string[] = fs.existsSync(path.join(__dirname, 'versions.json')) ? require('./versions.json') : []; -// Older release lines aren't built (see scripts/prepare.mts); the versions menu links to their docs on GitHub. -const archivedLines: { line: string; tag: string }[] = fs.existsSync(path.join(__dirname, 'archived-versions.json')) - ? require('./archived-versions.json') - : []; const versionOptions = Object.fromEntries( releasedLines.map((line, i) => [line, { label: version.startsWith(`${line}.`) ? `v${version}` : `v${line}.x`, noIndex: i > 0 }]) ); @@ -70,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, 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, material design, ios picker, expo, react native web, keyboard accessible', }, ], }; @@ -95,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, 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, 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` } }, @@ -209,10 +205,8 @@ const config: Config = { type: 'docsVersionDropdown', position: 'right', dropdownItemsAfter: [ - ...archivedLines.map(({ line, tag }) => ({ - href: `${REPO}/tree/${tag}/website/docs`, - label: `v${line}.x`, - })), + // Older release lines aren't built (see scripts/prepare.mts); each tag on GitHub has its docs. + { href: `${REPO}/tags`, label: 'Older versions' }, { to: '/changelog', label: 'Changelog' }, { href: `${REPO}/releases`, label: 'GitHub releases' }, ], diff --git a/website/scripts/prepare.mts b/website/scripts/prepare.mts index 539aa3b..1843932 100644 --- a/website/scripts/prepare.mts +++ b/website/scripts/prepare.mts @@ -1,8 +1,8 @@ // Generates the release-driven parts of the site before `docusaurus start|build`: // - versioned docs from git tags: the latest patch of each minor line becomes a version, so /docs matches // what's on npm and master's docs show as "Next" at /docs/next. Nothing is committed; tags are the source. -// Only the newest BUILT_LINES lines are built; older ones are listed in archived-versions.json and linked to their -// docs on GitHub, as Docusaurus recommends, since every built version is a full copy of the docs to compile. +// Only the newest BUILT_LINES lines are built; the versions menu links to older ones on GitHub, as Docusaurus +// recommends, since every built version is a full copy of the docs to compile. // - src/pages/changelog.md from the root CHANGELOG.md that release-please maintains. import { execFileSync, execSync } from 'node:child_process'; import fs from 'node:fs'; @@ -53,10 +53,6 @@ const writeVersions = () => { const all = [...releaseLines()]; const lines = new Map(all.slice(0, BUILT_LINES)); - const archived = all.slice(BUILT_LINES).map(([line, tag]) => ({ - line, - tag, - })); for (const [line, tag] of lines) { const dir = path.join(docsDir, `version-${line}`); fs.mkdirSync(dir, { recursive: true }); @@ -65,10 +61,7 @@ const writeVersions = () => { fs.writeFileSync(path.join(sidebarsDir, `version-${line}-sidebars.json`), JSON.stringify(sidebarsAt(tag), null, 2)); } fs.writeFileSync(path.join(site, 'versions.json'), JSON.stringify([...lines.keys()], null, 2)); - fs.writeFileSync(path.join(site, 'archived-versions.json'), JSON.stringify(archived, null, 2)); - console.log( - `[prepare] docs versions: ${[...lines.values()].join(', ') || 'none'}; on GitHub: ${archived.map((a) => a.tag).join(', ') || 'none'}` - ); + console.log(`[prepare] docs versions: ${[...lines.values()].join(', ') || 'none'}`); }; const writeChangelog = () => { diff --git a/website/sidebars.ts b/website/sidebars.ts index 81a8059..fc3e83e 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -111,6 +111,8 @@ const sidebars: SidebarsConfig = { 'resources/migration/slider', 'resources/migration/multi-slider', 'resources/migration/otp-entry', + 'resources/migration/ratings', + 'resources/migration/star-rating-widget', 'resources/migration/confirmation-code-field', ], }, diff --git a/website/src/catalog/catalog.constants.ts b/website/src/catalog/catalog.constants.ts index 2cb3eb9..3b3fcb2 100644 --- a/website/src/catalog/catalog.constants.ts +++ b/website/src/catalog/catalog.constants.ts @@ -32,6 +32,7 @@ export const PLAYGROUND = { colorPickerField: 'colorPickerField', slider: 'slider', otpField: 'otpField', + rating: 'rating', pickerField: 'pickerField', pickerSheet: 'pickerSheet', bottomSheet: 'bottomSheet', @@ -100,6 +101,7 @@ export const CATALOG: readonly CatalogEntry[] = [ { doc: 'components/color-picker', label: 'ColorPicker', playgrounds: [PLAYGROUND.colorPicker, PLAYGROUND.colorPickerField] }, { doc: 'components/slider', label: 'Slider', playgrounds: [PLAYGROUND.slider] }, { doc: 'components/otp-field', label: 'OtpField', playgrounds: [PLAYGROUND.otpField] }, + { doc: 'components/rating', label: 'Rating', playgrounds: [PLAYGROUND.rating] }, { label: 'Fields & sheets', pages: [ diff --git a/website/src/catalog/playgrounds.tsx b/website/src/catalog/playgrounds.tsx index 4b4d433..b96635f 100644 --- a/website/src/catalog/playgrounds.tsx +++ b/website/src/catalog/playgrounds.tsx @@ -21,6 +21,7 @@ import { MultiWheelPicker, OtpField, PhoneField, + Rating, SelectList, SelectPicker, Slider, @@ -453,6 +454,21 @@ export const PLAYGROUNDS: Record = { { prop: 'disabled', options: [false, true] }, ], }, + rating: { + platforms: false, + name: 'Rating', + component: Rating, + // A row of stars is as wide as its stars, so the preview centres it. + props: { style: { alignSelf: 'center' } }, + value: 3, + code: 'value={stars} onChange={setStars}', + controls: [ + { prop: 'step', options: [1, 0.5], default: 1 }, + { prop: 'max', options: [5, 10, 3], default: 5 }, + { prop: 'readOnly', options: [false, true] }, + { prop: 'disabled', options: [false, true] }, + ], + }, otpField: { name: 'OtpField', component: OtpField, diff --git a/website/src/components/PropPlayground.module.css b/website/src/components/PropPlayground.module.css index 20a3ce2..ae6bc84 100644 --- a/website/src/components/PropPlayground.module.css +++ b/website/src/components/PropPlayground.module.css @@ -25,7 +25,10 @@ var(--ifm-background-color); } +/* Laid out like a React Native root view, as the live examples are, so a component can centre itself with alignSelf. */ .stage { + display: flex; + flex-direction: column; width: 100%; max-width: 380px; } diff --git a/website/src/home/components/Hero.tsx b/website/src/home/components/Hero.tsx index a7a44c1..5e04aa9 100644 --- a/website/src/home/components/Hero.tsx +++ b/website/src/home/components/Hero.tsx @@ -29,8 +29,8 @@ export const Hero = () => { React Native app needs.

- Wheel, time, duration, date, month, range, select, country, phone, time zone, emoji and color pickers, a slider, an OTP input, - plus the sheets and fields to put them in. Native on iOS, Android and the web, yours to customize, and{' '} + 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.

diff --git a/website/src/home/home.constants.ts b/website/src/home/home.constants.ts index 1d4a151..96cc9cf 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 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 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; @@ -70,6 +70,7 @@ export const COMPONENTS = [ { doc: 'components/color-picker', name: 'ColorPicker', body: "iOS's grid, Google's circles, a web spectrum" }, { doc: 'components/slider', name: 'Slider', body: 'One value or a range, with steps and icons' }, { doc: 'components/otp-field', name: 'OtpField', body: 'Verification codes, SMS autofill, paste' }, + { 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/picker-fields', name: 'Fields', body: 'Tap a field, pick in a sheet, a dropdown or inline' }, diff --git a/website/src/live/live.constants.ts b/website/src/live/live.constants.ts index 267da44..15c590c 100644 --- a/website/src/live/live.constants.ts +++ b/website/src/live/live.constants.ts @@ -35,6 +35,7 @@ const EMOJI = ['EmojiPicker', 'EmojiPickerField', 'EmojiPickerSheet']; const SELECT = ['SelectPicker', 'CountryPicker']; const SLIDER = ['Slider']; const OTP = ['OtpField']; +const RATING = ['Rating']; /** * The side panel's props, offered for every live example that renders one of their components. @@ -78,7 +79,8 @@ export const LIVE_CONTROLS: readonly LiveControl[] = [ { prop: 'clearable', components: [...FIELDS, ...SELECT], options: BOOLEAN, default: false, requires: /\bonClear=/ }, { prop: 'showSteps', components: SLIDER, options: BOOLEAN, default: false }, { prop: 'secureTextEntry', components: OTP, options: BOOLEAN, default: false }, - { prop: 'disabled', components: [...FIELDS, ...SELECT, ...SLIDER, ...OTP], options: BOOLEAN, default: false }, + { prop: 'readOnly', components: RATING, options: BOOLEAN, default: false }, + { prop: 'disabled', components: [...FIELDS, ...SELECT, ...SLIDER, ...OTP, ...RATING], options: BOOLEAN, default: false }, ]; export const SNACK_URL = 'https://snack.expo.dev/'; diff --git a/website/src/showcase/ShowcaseApp.tsx b/website/src/showcase/ShowcaseApp.tsx index a6448e0..95f7696 100644 --- a/website/src/showcase/ShowcaseApp.tsx +++ b/website/src/showcase/ShowcaseApp.tsx @@ -10,11 +10,13 @@ import { DisplayScreen } from './components/DisplayScreen'; import { FlightsScreen } from './components/FlightsScreen'; import { HabitScreen } from './components/HabitScreen'; import { MeetingScreen } from './components/MeetingScreen'; +import { ReviewScreen } from './components/ReviewScreen'; import { ShopScreen } from './components/ShopScreen'; import { SignUpScreen } from './components/SignUpScreen'; import { TableScreen } from './components/TableScreen'; import { TimerScreen } from './components/TimerScreen'; import { TripsScreen } from './components/TripsScreen'; +import { VerifyScreen } from './components/VerifyScreen'; import type { ScreenProps, ShowcaseScreenKey } from './showcase.types'; const SCREENS: Record> = { @@ -31,6 +33,8 @@ const SCREENS: Record> = { trips: TripsScreen, delivery: DeliveryScreen, display: DisplayScreen, + verify: VerifyScreen, + review: ReviewScreen, }; interface ShowcaseAppProps extends ScreenProps { diff --git a/website/src/showcase/components/ReviewScreen.tsx b/website/src/showcase/components/ReviewScreen.tsx new file mode 100644 index 0000000..bd56e6e --- /dev/null +++ b/website/src/showcase/components/ReviewScreen.tsx @@ -0,0 +1,58 @@ +import { useState } from 'react'; +import { Text, View } from 'react-native'; + +import { Rating, usePickerTheme } from '@elik56a/react-native-pickers'; +import { ActionButton } from '../../home/demo/components/ActionButton'; +import { AppScreen } from '../../home/demo/components/AppScreen'; +import { REVIEW, REVIEW_CATEGORIES } from '../showcase.constants'; +import { styles } from '../showcase.styles'; +import type { ScreenProps } from '../showcase.types'; + +export const ReviewScreen = ({ platform }: ScreenProps) => { + const { colors } = usePickerTheme(); + const [overall, setOverall] = useState(0); + const [scores, setScores] = useState>({}); + return ( + + } + > + + {REVIEW.place} + + + + {REVIEW.average} · {REVIEW.reviews} reviews + + + + + + Your rating + {overall ? `${overall} of 5` : '–'} + + + + {REVIEW_CATEGORIES.map((category) => ( + + {category} + + setScores((prev) => ({ + ...prev, + [category]: value, + })) + } + accessibilityLabel={category} + /> + + ))} + + ); +}; diff --git a/website/src/showcase/components/VerifyScreen.tsx b/website/src/showcase/components/VerifyScreen.tsx new file mode 100644 index 0000000..4a487bd --- /dev/null +++ b/website/src/showcase/components/VerifyScreen.tsx @@ -0,0 +1,41 @@ +import { useState } from 'react'; +import { Text, View } from 'react-native'; + +import { OtpField, PhoneField, usePickerTheme } from '@elik56a/react-native-pickers'; +import { ActionButton } from '../../home/demo/components/ActionButton'; +import { AppScreen } from '../../home/demo/components/AppScreen'; +import { deviceSheetProps } from '../../home/demo/demo.utils'; +import { VERIFY } from '../showcase.constants'; +import { styles } from '../showcase.styles'; +import type { ScreenProps } from '../showcase.types'; + +export const VerifyScreen = ({ platform, screenHeight }: ScreenProps) => { + const { colors } = usePickerTheme(); + const [phone, setPhone] = useState(VERIFY.phone); + const [code, setCode] = useState(''); + const verified = code.length === VERIFY.codeLength; + return ( + + } + > + + + Code + + + + Any {VERIFY.codeLength} digits work here. Try pasting “Your code is 482-913”. + + + ); +}; diff --git a/website/src/showcase/showcase.constants.ts b/website/src/showcase/showcase.constants.ts index 17770a4..8151a63 100644 --- a/website/src/showcase/showcase.constants.ts +++ b/website/src/showcase/showcase.constants.ts @@ -16,6 +16,8 @@ export const SHOWCASE_SCREEN = { trips: 'trips', delivery: 'delivery', display: 'display', + verify: 'verify', + review: 'review', } as const; export const SHOWCASE: ShowcaseItem[] = [ @@ -100,6 +102,18 @@ export const SHOWCASE: ShowcaseItem[] = [ blurb: 'Brightness and text size preview live as you drag; the text size snaps to its marks, and focus hours are a range.', components: ['Slider'], }, + { + key: SHOWCASE_SCREEN.verify, + title: 'Verify your phone', + blurb: 'A phone number, then the code texted to it: the keyboard offers it from the message, and a paste fills every box.', + components: ['PhoneField', 'OtpField'], + }, + { + key: SHOWCASE_SCREEN.review, + title: 'Rate your stay', + 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.trips, title: 'Trip planner', @@ -289,3 +303,16 @@ export const FOCUS_MARKS = [ /** The preview is never fully dark, so it stays readable at the lowest brightness. */ export const MIN_PREVIEW_OPACITY = 0.3; + +export const VERIFY = { + phone: '+12015550123', + codeLength: 6, +} as const; + +export const REVIEW = { + place: 'Casa do Mar', + city: 'Lisbon', + average: 4.6, + reviews: 312, +} as const; +export const REVIEW_CATEGORIES = ['Cleanliness', 'Location', 'Value'] as const; diff --git a/website/src/showcase/showcase.styles.ts b/website/src/showcase/showcase.styles.ts index cf7e773..14d21c3 100644 --- a/website/src/showcase/showcase.styles.ts +++ b/website/src/showcase/showcase.styles.ts @@ -43,4 +43,7 @@ export const styles = StyleSheet.create({ settingLabel: { fontSize: 15, fontWeight: '500' }, settingValue: { fontSize: 15 }, icon: { fontSize: 16 }, + hint: { fontSize: 13 }, + scoreRow: { flexDirection: 'row', alignItems: 'center', gap: 6 }, + categoryRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, });