From 616173861eb298dd2041705f9c5f84d16892f990 Mon Sep 17 00:00:00 2001 From: Sid Jain Date: Tue, 28 Jul 2026 04:25:31 +0000 Subject: [PATCH] feat!: build a complete cross-platform rating system Add precise scroll-aware rating interactions, native and Web accessibility, exact static displays, and generic semantic scales. Ship the Expo showcase, competitive evidence, migration guide, and packed consumer/SSR compatibility gates with the same release. BREAKING CHANGE: rating root interaction ownership, primary-axis item geometry, default visuals, and accessibility semantics have changed; see the README migration guide. --- .github/RELEASING.md | 2 +- .github/fixtures/consumer/index.tsx | 62 +- .github/scripts/check-expo-web.sh | 137 ++++ .github/workflows/ci.yml | 27 +- README.md | 450 +++++++++-- __tests__/model.test.ts | 297 +++++++ __tests__/rating-display.test.tsx | 117 +++ __tests__/rating-scale.test.tsx | 515 ++++++++++++ __tests__/rating.test.tsx | 1010 +++++++++++++++++++----- __tests__/reduced-motion.test.tsx | 180 +++++ __tests__/root-props.test.ts | 34 + __tests__/web.test.tsx | 540 +++++++++++++ docs/ARCHITECTURE.md | 480 +++++++++++ docs/COMPETITIVE_ANALYSIS.md | 280 +++++++ example/app.json | 14 + example/app.tsx | 365 +++++++++ example/index.ts | 5 + example/package.json | 28 + example/tsconfig.json | 9 + hk.pkl | 2 +- oxlint.config.ts | 7 +- package.json | 22 +- src/index.ts | 21 +- src/internal/interactive-root.tsx | 201 +++++ src/internal/model.ts | 390 +++++++++ src/internal/rating-track.tsx | 610 ++++++++++++++ src/internal/reduced-motion.ts | 85 ++ src/internal/root-props.ts | 66 ++ src/internal/use-rating-interaction.ts | 802 +++++++++++++++++++ src/internal/use-selection-pulse.ts | 140 ++++ src/rating-display.tsx | 119 +++ src/rating-scale.tsx | 519 ++++++++++++ src/rating.tsx | 829 +++++++------------ src/types.ts | 373 +++++++++ 34 files changed, 7934 insertions(+), 804 deletions(-) create mode 100755 .github/scripts/check-expo-web.sh create mode 100644 __tests__/model.test.ts create mode 100644 __tests__/rating-display.test.tsx create mode 100644 __tests__/rating-scale.test.tsx create mode 100644 __tests__/reduced-motion.test.tsx create mode 100644 __tests__/root-props.test.ts create mode 100644 __tests__/web.test.tsx create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/COMPETITIVE_ANALYSIS.md create mode 100644 example/app.json create mode 100644 example/app.tsx create mode 100644 example/index.ts create mode 100644 example/package.json create mode 100644 example/tsconfig.json create mode 100644 src/internal/interactive-root.tsx create mode 100644 src/internal/model.ts create mode 100644 src/internal/rating-track.tsx create mode 100644 src/internal/reduced-motion.ts create mode 100644 src/internal/root-props.ts create mode 100644 src/internal/use-rating-interaction.ts create mode 100644 src/internal/use-selection-pulse.ts create mode 100644 src/rating-display.tsx create mode 100644 src/rating-scale.tsx create mode 100644 src/types.ts diff --git a/.github/RELEASING.md b/.github/RELEASING.md index 3ec16bb..098acc5 100644 --- a/.github/RELEASING.md +++ b/.github/RELEASING.md @@ -25,7 +25,7 @@ After `release.yml` is on `master` and before merging the first release pull req ``` 4. Keep Actions' default token read-only and allow Actions to create pull requests. The release job grants only the three write permissions it needs: contents, issues, and pull requests. -5. Require `Check`, `React Native (minimum)`, `React Native (current)`, and `Dependency review` on `master`, and protect `v*` tags from deletion or force updates. +5. Require `Check`, `React Native (minimum)`, `React Native (current)`, `Expo Web`, and `Dependency review` on `master`, and protect `v*` tags from deletion or force updates. The first automated pull request may require a maintainer to approve its CI run because it is opened with `GITHUB_TOKEN`. diff --git a/.github/fixtures/consumer/index.tsx b/.github/fixtures/consumer/index.tsx index a282301..9245e56 100644 --- a/.github/fixtures/consumer/index.tsx +++ b/.github/fixtures/consumer/index.tsx @@ -1,23 +1,65 @@ import { useState } from "react"; import { Text } from "react-native"; -import { Rating } from "react-native-rating"; -import type { RatingRenderItemProps } from "react-native-rating"; +import { Rating, RatingDisplay, RatingScale } from "react-native-rating"; +import type { + RatingRenderItemProps, + RatingScaleItem, + RatingScaleRenderItemProps, +} from "react-native-rating"; + +type Sentiment = "negative" | "neutral" | "positive"; + +const sentimentItems = [ + { content: "😞", label: "Negative", value: "negative" }, + { content: "😐", label: "Neutral", value: "neutral" }, + { content: "πŸ™‚", label: "Positive", value: "positive" }, +] as const satisfies readonly RatingScaleItem[]; const renderItem = ({ fill, index }: RatingRenderItemProps) => ( {`${index + 1}:${fill}`} ); +const renderScaleItem = ({ + content, + label, + selected, +}: RatingScaleRenderItemProps) => ( + {`${content ?? label}:${selected}`} +); + +// @ts-expect-error Interactive accessibility ownership cannot be overridden. +const _hiddenInteractiveRating = ; + export const RatingConsumer = () => { const [value, setValue] = useState(3.5); + const [sentiment, setSentiment] = useState(null); return ( - + <> + + + + ); }; diff --git a/.github/scripts/check-expo-web.sh b/.github/scripts/check-expo-web.sh new file mode 100755 index 0000000..dcb4fc2 --- /dev/null +++ b/.github/scripts/check-expo-web.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash + +set -euo pipefail + +: "${PACKAGE_TARBALL:?PACKAGE_TARBALL is required}" + +example_directory="$(mktemp -d)" +trap 'rm -rf -- "$example_directory"' EXIT + +cp -R example/. "$example_directory" + +PACKAGE_TARBALL="$(realpath "$PACKAGE_TARBALL")" \ + EXAMPLE_DIRECTORY="$example_directory" \ + node --input-type=module <<'NODE' +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +const packagePath = join( + process.env.EXAMPLE_DIRECTORY, + "package.json" +); +const packageJson = JSON.parse(await readFile(packagePath, "utf8")); +packageJson.dependencies["react-native-rating"] = + `file:${process.env.PACKAGE_TARBALL}`; +await writeFile(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`); +NODE + +( + cd "$example_directory" + bun install --ignore-scripts + bun run typecheck + bun run export:web + test -s dist/index.html + + mv node_modules/react-native node_modules/react-native-native + ln -s react-native-web node_modules/react-native + + node --input-type=module <<'NODE' +import assert from "node:assert/strict"; + +const diagnostics = []; +const originalError = console.error; +const originalWarn = console.warn; +console.error = (...messages) => { + diagnostics.push(messages.join(" ")); +}; +console.warn = (...messages) => { + diagnostics.push(messages.join(" ")); +}; + +try { + const { createElement } = await import("react"); + const { renderToStaticMarkup } = await import("react-dom/server"); + const { StyleSheet } = await import("react-native"); + const { Rating, RatingDisplay, RatingScale } = + await import("react-native-rating"); + + const slider = renderToStaticMarkup( + createElement(Rating, { + animated: false, + interactionMode: "tap-and-drag", + step: 0.5, + testID: "rating", + value: 2.5, + }) + ); + const decorative = renderToStaticMarkup( + createElement(RatingDisplay, { + decorative: true, + testID: "display", + value: 4.37, + }) + ); + const scale = renderToStaticMarkup( + createElement(RatingScale, { + animated: false, + items: [ + { label: "Negative", value: -1 }, + { label: "Neutral", value: 0 }, + { label: "Positive", value: 1 }, + ], + testID: "scale", + value: 0, + }) + ); + const styleSheet = StyleSheet.getSheet(); + const getTag = (markup, testID) => { + const tag = markup.match( + new RegExp(`<[^>]*data-testid="${testID}"[^>]*>`, "u") + )?.[0]; + + assert.ok(tag, `Missing rendered element: ${testID}`); + return tag; + }; + const hasPointerClass = ( + markup, + testID, + rootPointerEvents, + childPointerEvents + ) => { + const tag = getTag(markup, testID); + const classNames = tag.match(/class="([^"]+)"/u)?.[1]?.split(/\s+/u) ?? []; + + return classNames.some( + (className) => + styleSheet.textContent.includes( + `.${className}{pointer-events:${rootPointerEvents}!important;}` + ) && + styleSheet.textContent.includes( + `.${className}>* {pointer-events:${childPointerEvents};}` + ) + ); + }; + + assert.match(slider, /role="slider"/u); + assert.match(slider, /aria-valuenow="2.5"/u); + assert.doesNotMatch(slider, /pointer-events:box-only/u); + assert.equal( + hasPointerClass(slider, "rating-control", "auto", "none"), + true + ); + assert.match(decorative, /aria-hidden="true"/u); + assert.doesNotMatch(decorative, /role="img"/u); + assert.equal( + hasPointerClass(decorative, "display-control", "none", "none"), + true + ); + assert.match(scale, /role="slider"/u); + assert.match(scale, /aria-valuenow="2"/u); + assert.match(scale, /aria-valuetext="Neutral"/u); + assert.deepEqual(diagnostics, []); +} finally { + console.error = originalError; + console.warn = originalWarn; +} +NODE +) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e73256..9d7a89f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,7 +76,7 @@ jobs: include: - label: minimum react: 19.1.1 - react-native: 0.82.1 + react-native: 0.82.0 react-types: 19.1.17 - label: current react: current @@ -105,6 +105,31 @@ jobs: REACT_VERSION: ${{ matrix.react }} run: bash .github/scripts/check-package.sh + expo-web: + name: Expo Web + needs: check + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Check out Expo example + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install toolchain + uses: jdx/mise-action@9e7f7633ff6f6d6048a9418a68d48f288f50eb14 # v4.2.3 + with: + cache: true + version: 2026.7.15 + - name: Download checked package + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: package + path: ${{ runner.temp }}/package + - name: Type-check, export, and server-render Expo Web + env: + PACKAGE_TARBALL: ${{ runner.temp }}/package/react-native-rating.tgz + run: bash .github/scripts/check-expo-web.sh + dependency-review: name: Dependency review if: github.event_name == 'pull_request' diff --git a/README.md b/README.md index 1ac6845..355959a 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,38 @@ -# react-native-rating +# React Native Rating -A small, accessible rating input for modern React Native. Zero runtime dependencies, sensible touch targets, fractional values, and motion that respects the system preference. +[![npm version](https://img.shields.io/npm/v/react-native-rating.svg)](https://www.npmjs.com/package/react-native-rating) [![npm downloads](https://img.shields.io/npm/dm/react-native-rating.svg)](https://www.npmjs.com/package/react-native-rating) [![CI](https://github.com/f0rr0/react-native-rating/actions/workflows/ci.yml/badge.svg)](https://github.com/f0rr0/react-native-rating/actions/workflows/ci.yml) [![license](https://img.shields.io/npm/l/react-native-rating.svg)](./LICENSE.md) + +Accessible React Native star rating, drag rating, and semantic feedback scale for iOS, Android, Expo, and React Native Web. + +Fractional values, RTL and vertical layouts, keyboard and screen-reader support, a FlatList-friendly display path, and zero runtime dependencies. + +## Why this rating component? + +- **One accessible control.** Native assistive technologies get an adjustable control; the Web gets a real ARIA slider with keyboard input. +- **Tap or deliberate drag.** Opt-in dragging respects the primary axis and yields to a parent ScrollView before drag intent is clear. +- **One fractional model.** Pointer input, controlled state, visual fill, keyboard changes, and announced values use the same integer-tick math. +- **Ratings beyond stars.** `RatingScale` supports NPS, Likert, emoji, string values, zero, negative values, and a nullable empty selection. +- **Static lists stay static.** `RatingDisplay` creates no responder, animation, or reduced-motion subscription. +- **Universal by design.** Horizontal, vertical, LTR, RTL, native, and Web behavior are explicit rather than inferred from icon order. +- **Bring any visual.** The default star uses React Native core `Text`; `renderItem` accepts an icon, SVG, image, emoji, or design-system component. ## Install +```sh +npm install react-native-rating +``` + +```sh +yarn add react-native-rating +``` + ```sh bun add react-native-rating ``` -Requires React 19.1+ and React Native 0.82+. +The package requires React 19.1.1 or newer and React Native 0.82 or newer. It uses only React and React Native core APIs, so Expo projects need no native module setup. React Native Web support assumes the application already has its normal Web target configured. -## Use +## Quick start ```tsx import { useState } from "react"; @@ -21,7 +43,7 @@ export function ReviewScore() { return ( +``` + +- `step` is the selectable precision within each item and is clamped to `0.01`–`1`. +- `min` is the smallest selectable nonzero value. Zero remains the unrated sentinel. +- With `allowClear`, a true same-value tap, decrement at the minimum, or Home on the Web clears the rating. +- Drag callbacks are deduplicated by selectable tick, including when a controlled parent updates slowly. +- Gaps are measured as part of the track; either half of a gap resolves to the nearest selectable edge. +- Counts and layout inputs are defensive boundaries: `max`/scale items are capped at `100`, and finite `size`, `gap`, and `itemExtent` values are capped at `1024`. + +## RTL and vertical ratings + +`direction="auto"` follows `I18nManager.isRTL`. Override it for a locally directed control, or switch the interaction axis independently: + +```tsx + + + +``` + +Horizontal fill and pointer progression follow `direction`. Vertical ratings progress from bottom to top, so Up Arrow and the native increment action both move upward. On `RatingScale`, `reversed` remaps the ordered semantic choices without reversing pointer geometry or locale direction. -## Customize +## Accessible on native and Web -The built-in star is rendered with core React Native `Text`. A render slot keeps the package independent of any icon or SVG library: +Interactive ratings have one focus stop, not one stop per star: + +- iOS and Android receive `accessibilityRole="adjustable"`, the current value, and increment/decrement accessibility actions. +- React Native Web receives `role="slider"`, direct `aria-value*` attributes, a visible two-color focus indicator, and standard slider keys. Use `focusStyle` to compose a product-specific focused treatment after that default. +- Right Arrow and Up Arrow increment; Left Arrow and Down Arrow decrement; Home selects the current lower bound (empty when already empty or clearing is allowed, otherwise `min`); End selects the maximum. +- Visible items retain a 44pt cross-axis target on iOS and 48dp elsewhere without adding artificial width between adjacent choices. +- The quiet completion pulse is disabled when the system requests reduced motion. Dragging itself is never animated. + +Always provide a useful `accessibilityLabel`. Localize the spoken value with `formatAccessibilityValue`: ```tsx ( - + accessibilityLabel="Satisfaction" + formatAccessibilityValue={(value, max) => `${value} de ${max} estrellas`} + value={4.5} +/> +``` + +Use `disabled` for a temporarily unavailable input. Use `RatingDisplay` or `readOnly` for content that is not an input. + +## FlatList: interactive rows and exact displays + +### Interactive ratings + +Own each row's controlled value by its stable domain ID. Do not use one shared rating value or the visible list index: + +```tsx +import { useState } from "react"; +import { FlatList } from "react-native"; +import { Rating } from "react-native-rating"; + +export function ReviewEditor({ reviews }: { reviews: Review[] }) { + const [ratingsById, setRatingsById] = useState>({}); + + return ( + review.id} + renderItem={({ item }) => ( + { + setRatingsById((current) => ({ + ...current, + [item.id]: nextValue, + })); + }} + value={ratingsById[item.id] ?? item.rating} + /> + )} + /> + ); +} +``` + +For expensive persistence, save the keyed row in `onChangeEnd` and keep `onChange` for responsive local state. + +### Read-only and high-density lists + +`RatingDisplay` is the dedicated read-only component. It renders an exact aggregate such as `4.37` by default; pass `step` only when display snapping is intentional. + +```tsx +import { FlatList } from "react-native"; +import { RatingDisplay } from "react-native-rating"; + + review.id} + renderItem={({ item }) => ( + )} - value={4} +/>; +``` + +`` also selects the static path, but `RatingDisplay` makes the intent clearest in list cells and aggregate views. When adjacent text already communicates the same value, pass `decorative` to hide the duplicate visual from assistive technology: + +```tsx +4.37 out of 5 + +``` + +## NPS, Likert, and emoji scales + +`RatingScale` is generic over finite numbers or strings. `null` means β€œno selection,” so zero and negative values remain first-class answers. + +### Net Promoter Score + +```tsx +import { useState } from "react"; +import { RatingScale } from "react-native-rating"; + +const NPS_ITEMS = Array.from({ length: 11 }, (_, score) => ({ + content: score, + label: `${score} out of 10`, + value: score, +})); + +export function NpsQuestion() { + const [score, setScore] = useState(null); + + return ( + + ); +} +``` + +### Emoji Likert scale + +```tsx +const EXPERIENCE = [ + { value: "very-bad", label: "Very bad", content: "😞" }, + { value: "bad", label: "Bad", content: "πŸ™" }, + { value: "neutral", label: "Neutral", content: "😐" }, + { value: "good", label: "Good", content: "πŸ™‚" }, + { value: "very-good", label: "Very good", content: "😍" }, +] as const; + +; +``` + +Use `selectionMode="single"` for one highlighted choice (the default), or `selectionMode="cumulative"` for star-like fill through the selected item. Each nonblank `label` supplies semantic meaning even when `content` is only an emoji or icon. + +`itemExtent` controls each choice's primary-axis length independently of `size`. Increase it for longer labels in a horizontal scale without inflating the cross-axis target. It is never smaller than `size`; for a long custom vertical presentation, use `renderItem` and its `itemExtent` value to lay out the content deliberately. A read-only scale can also be `decorative` when equivalent adjacent content is already accessible. + +## Custom rendering + +The default star has no icon-library dependency. A render slot receives all state needed to keep custom visuals aligned with interaction and accessibility: + +```tsx + ( + + )} + step={0.1} + value={4.3} /> ``` -`renderItem` receives `fill` from 0 to 1, plus `index`, `value`, `size`, `pressed`, `disabled`, and both colors. +Numeric `renderItem` receives: + +- `fill` from `0` to `1`, `fillOrigin`, `index`, and the item's one-based `value`; +- resolved `direction` and `orientation`; +- `pressed`, `disabled`, `size`, `activeColor`, and `inactiveColor`. + +`RatingScale` has its own typed `renderItem` with the semantic `value`, `label`, `content`, `selected`, `itemExtent`, and the same interaction/visual context. Returned content is visual-only; put control semantics and labels on the root component. + +## Controlled values and callback contract -## Props +```tsx + { + // Fast local state: may run for each distinct tick during a drag. + setValue(nextValue); + }} + onChangeEnd={(finalValue, { cancelled, source }) => { + // Persistence/analytics: once per accepted interaction. + if (!cancelled) { + saveRating(finalValue, source); + } + }} + onInteractionStart={(startValue, { source }) => { + beginRatingInteraction(startValue, source); + }} + value={value} +/> +``` + +- `onInteractionStart` runs once when an interaction is accepted. +- `onChange` runs synchronously for each distinct selected value. A drag can produce multiple calls; a no-op interaction produces none. +- `onChangeEnd` runs once with the final value and `{ source: "pointer" | "keyboard" | "accessibility", cancelled }`. +- A responder termination after drag acceptance reports `cancelled: true`. Cross-axis scroll intent before acceptance produces no lifecycle callbacks. +- In controlled mode the prop remains the source of truth. A local draft keeps the gesture responsive until the parent renders the new value. + +Apply expensive network writes in `onChangeEnd`, not `onChange`. + +## Core props + +### `Rating` | Prop | Default | Purpose | | --- | --- | --- | -| `value` | β€” | Controlled value | +| `value` | β€” | Controlled numeric value | | `defaultValue` | `0` | Initial uncontrolled value | -| `onChange` | β€” | Receives a user-selected value | -| `max` | `5` | Number of items | -| `step` | `1` | Per-item selection precision (`0.01`–`1`) | -| `allowClear` | `false` | Clear by selecting the current value | -| `disabled` | `false` | Disable interaction | -| `readOnly` | `false` | Expose the rating as static content | -| `size` | `28` | Visible item size | -| `gap` | `0` | Space between touch targets | -| `activeColor` | `#E8A317` | Selected color | -| `inactiveColor` | `#D5D9E0` | Unselected color | -| `animated` | `true` | Enable subtle, reduced-motion-aware feedback | -| `formatAccessibilityValue` | `"x out of y"` | Localize the announced value | -| `renderItem` | text star | Render a custom item | - -All non-conflicting React Native `View` props, including `style`, `testID`, and `accessibilityLabel`, are forwarded to the root view. - -## Accessibility - -Interactive ratings are exposed as one adjustable control with native increment and decrement actions, avoiding five repetitive screen-reader stops. The current, minimum, and maximum values are announced. Visible item targets remain at least 44pt on iOS and 48dp elsewhere. - -## Migrating from 2.x - -Version 3 is a deliberate API reset: - -| 2.x | 3.x | -| --------------------- | --------------------------------------- | -| default import | `import { Rating } from "…"` | -| `initial` | `defaultValue` | -| internal state only | `value` + `onChange`, or `defaultValue` | -| `editable={false}` | `readOnly` | -| image props required | built-in star or `renderItem` | -| animation callbacks | respond to the synchronous `onChange` | -| style-sized touchable | accessible 44pt/48dp item touch targets | +| `onChange` | β€” | Each distinct user-selected value | +| `onInteractionStart` | β€” | Start of an accepted interaction | +| `onChangeEnd` | β€” | Final value, source, and cancellation state | +| `max` | `5` | Item count, clamped to `1`–`100` | +| `min` | `0` | Smallest selectable nonzero value | +| `step` | `1` | Per-item precision, clamped to `0.01`–`1` | +| `allowClear` | `false` | Permit returning to the zero sentinel | +| `interactionMode` | `"tap"` | `"tap"` or `"tap-and-drag"` | +| `disabled` | `false` | Disable input and expose disabled semantics | +| `readOnly` | `false` | Use the static display path | +| `animated` | `true` | Enable reduced-motion-aware completion feedback | +| `focusStyle` | β€” | Compose a custom Web treatment after the default focused style | +| `renderItem` | text star | Render a custom visual item | + +### `RatingDisplay` + +| Prop | Default | Purpose | +| --- | --- | --- | +| `value` | required | Exact finite display value, clamped to range | +| `disabled` | `false` | Dim the display and expose disabled semantics to custom items | +| `decorative` | `false` | Hide a duplicate visual from assistive technology | +| `max` | `5` | Item count | +| `step` | β€” | Optional visual snapping | +| `formatAccessibilityValue` | `"x out of y"` | Localized accessible value | +| `renderItem` | text star | Render a custom visual item | + +### `RatingScale` + +| Prop | Default | Purpose | +| --- | --- | --- | +| `items` | required | Ordered `{ value, label, content? }` choices | +| `value` | β€” | Controlled value; `null` means empty | +| `defaultValue` | `null` | Initial uncontrolled value | +| `onChange` | β€” | Each distinct semantic value | +| `onInteractionStart` | β€” | Start of an accepted interaction | +| `onChangeEnd` | β€” | Final semantic value, source, and cancellation state | +| `allowClear` | `false` | Permit a `null` selection | +| `disabled` | `false` | Disable input and expose disabled semantics | +| `readOnly` | `false` | Use the static scale path | +| `decorative` | `false` | Hide a read-only duplicate visual from assistive technology | +| `selectionMode` | `"single"` | `"single"` or `"cumulative"` visuals | +| `reversed` | `false` | Reverse semantic progression | +| `interactionMode` | `"tap"` | `"tap"` or `"tap-and-drag"` | +| `itemExtent` | `size` | Primary-axis length per choice; normalized to at least `size` | +| `animated` | `true` | Enable reduced-motion-aware completion feedback | +| `focusStyle` | β€” | Compose a custom Web treatment after the default focused style | +| `renderItem` | label/content | Render a typed semantic item | + +All three components accept `size`, `gap`, `activeColor`, `inactiveColor`, `direction`, `orientation`, a component-specific `formatAccessibilityValue`, `style`, `testID`, `ref`, and non-conflicting React Native `View` props. `Rating` and `RatingScale` share the interactive lifecycle, `interactionMode`, `disabled`, `readOnly`, `animated`, and `focusStyle`; `RatingDisplay` is always static. + +The components own their root responder, keyboard, accessibility, focusability, pointer-routing, role, tab-order, and numeric ARIA props. These keys are omitted from the TypeScript root-prop contract and removed again at runtime to protect JavaScript consumers and broad object spreads. Consumer focus/blur handlers and visual styles still compose normally. + +## Performance + +- `RatingDisplay` and read-only scales skip responder handlers, animation values, and reduced-motion listeners. +- One root track handles pointer input; items do not each allocate a press or pan responder. +- Numeric and semantic visual items are memoized, and one shared transform value pulses only the completed item. +- Animated native targets use the native driver on iOS and Android and are marked non-interactive; no animation is scheduled during a drag. +- Reduced-motion state uses one shared native subscription, regardless of the number of interactive ratings. + +For large lists, prefer `RatingDisplay`, keep `items` arrays and `renderItem` callbacks stable, and give the list a stable `keyExtractor`. + +## Feature comparison + +This is a source-level comparison of the versions audited on 2026-07-28, not a popularity claim. β€œPartial” means the package covers some of the behavior but not the complete row. + +| Capability | `react-native-rating` | [`react-native-star-rating-widget` 1.11.0](https://github.com/bviebahn/react-native-star-rating-widget/tree/c8906b677061ca7ac252c191419495e8cdefa381) | [`@kolking/react-native-rating` 1.4.1](https://github.com/kolking/react-native-rating/tree/19382752e7a47cd5370d0e5206fc107ad52be3e2) | [`react-native-ratings` 8.1.0](https://github.com/Monte9/react-native-ratings/tree/0a8f16b4380626556b636e8883b06c0db9e885ec) | +| --- | --- | --- | --- | --- | +| No required third-party runtime package | Yes | No; requires `react-native-svg` | Yes | No; depends on `lodash` | +| Deliberate, cross-axis-aware drag ownership | Yes | No; start/move capture is unconditional | No; starts immediately and refuses termination | No; starts immediately | +| Fraction selection and visual fill share one exact model | Yes, `0.01`–`1` | Fixed full/half/quarter | Partial; decimal display, whole-item input | Partial; an [open issue](https://github.com/Monte9/react-native-ratings/issues/155) reports jump/UI divergence | +| Dedicated allocation-light static component | Yes | Yes | No; uses `disabled` on the interactive component | No; `readonly` stays in the swipe component | +| Native adjustable semantics plus Web ARIA slider keys | Yes | Partial; native actions, no direct Web key handler in audited source | No control semantics in audited source | No; [accessibility remains open](https://github.com/Monte9/react-native-ratings/issues/172) | +| Explicit LTR/RTL override and vertical orientation | Yes | Partial; automatic RTL, horizontal | Partial; automatic RTL, horizontal | No; [RTL issue open](https://github.com/Monte9/react-native-ratings/issues/81) | +| Generic NPS/Likert/emoji scale with `null`, zero, negatives, and strings | Yes | No | No | No; [bidirectional scale request open](https://github.com/Monte9/react-native-ratings/issues/189) | + +See the dated [competitive analysis](./docs/COMPETITIVE_ANALYSIS.md) for the search method, source audit, issue evidence, and positioning decisions. See the [architecture and release plan](./docs/ARCHITECTURE.md) for interaction, performance, accessibility, and QA invariants. + +## Compatibility + +| Dependency or platform | Supported baseline | +| --- | --- | +| React | `>=19.1.1` | +| React Native | `>=0.82.0` | +| iOS and Android | React Native core renderer | +| Expo | Managed or bare projects matching the peer versions | +| Web | React Native Web through the application's existing setup; packed-package export and DOM server-render smoke coverage in CI | +| Module formats | ESM and CommonJS, with TypeScript declarations | + +## Migrating + +### From 3.x to 4.0 + +The value contract remains familiar: existing `Rating` usage stays controlled with `value`/`onChange` or uncontrolled with `defaultValue`. Version 4 is still a major release because the root interaction ownership, item geometry, and default visuals change: + +- `Rating`, `RatingDisplay`, and `RatingScale` now own responder and keyboard callbacks; `accessible`/`focusable`/`pointerEvents`; role and tab order; accessibility actions, state, value, and subtree-hiding props; and the corresponding `aria-*` range/label/disabled/hidden attributes. Remove forwarded overrides such as `onKeyDown`, responder callbacks, `role`, `tabIndex`, and `aria-value*`; the TypeScript API excludes them and a runtime sanitizer drops them from JavaScript spreads. Use the documented rating callbacks and accessibility props instead. +- Interactive items now use their natural `size` along the primary axis and retain the 44pt/48dp minimum only on the cross axis. `gap` is visual space between items. Recheck snapshots or layouts that depended on the wider v3 per-item touch boxes. +- The default stars are filled/outlined (`β˜…`/`β˜†`) and the default colors are darker for stronger state distinction and light-surface contrast. Pass `renderItem`, `activeColor`, and `inactiveColor` to preserve a custom visual treatment. +- Web focus now uses a two-color indicator by default. Use `focusStyle` to compose an application-specific focused treatment. +- Consumer root styles still compose, but structural `direction` is owned so rendered order and pointer geometry cannot diverge. Vertical controls now progress bottom-to-top. + +- Add `interactionMode="tap-and-drag"` to opt into dragging; tap remains the default. +- Use the new `onChangeEnd` for persistence and analytics. +- Replace list-cell `` with `RatingDisplay` when you want explicit static intent and exact unsnapped aggregate values. Mark duplicate static visuals with `RatingDisplay decorative` or a read-only `RatingScale decorative`. +- Use `min` for the verified minimum-rating request. +- Use `RatingScale` rather than overloading zero when zero or negative values are meaningful. Use `itemExtent` when a semantic choice needs more primary-axis room than its visual `size`. + +All of these changes ship together in one version; there is no intermediate API or feature-flag migration. + +### From 2.x + +Version 3 introduced the modern API: + +| 2.x | 3.x and newer | +| --------------------- | ---------------------------------------------- | +| default import | `import { Rating } from "react-native-rating"` | +| `initial` | `defaultValue` | +| internal state only | `value` + `onChange`, or `defaultValue` | +| `editable={false}` | `readOnly` or `RatingDisplay` | +| image props required | built-in star or `renderItem` | +| animation callbacks | synchronous input callbacks | +| style-sized touchable | accessible 44pt/48dp cross-axis target | ## Develop @@ -101,7 +451,7 @@ mise run check `bun run fix` applies the Ultracite Oxlint/Oxfmt rules. -Releases use a reviewable Release Please pull request and npm trusted publishing. See [the release guide](https://github.com/f0rr0/react-native-rating/blob/master/.github/RELEASING.md). +Releases use a reviewable Release Please pull request and npm trusted publishing. See the [release guide](https://github.com/f0rr0/react-native-rating/blob/master/.github/RELEASING.md). ## License diff --git a/__tests__/model.test.ts b/__tests__/model.test.ts new file mode 100644 index 0000000..9d39b46 --- /dev/null +++ b/__tests__/model.test.ts @@ -0,0 +1,297 @@ +import { describe, expect, it } from "@jest/globals"; + +import { + createNumericModel, + getClosestTick, + getDecrementTick, + getFillOrigin, + getHomeTick, + getIncrementTick, + getNumericTickFromPosition, + getScaleTickFromPosition, + getTickItemIndex, + getTrackExtent, + getValueFromTick, + MAX_ITEMS, + MAX_LAYOUT_VALUE, + normalizeDisplayValue, + normalizeGap, + normalizeMax, + normalizeNumericTick, + normalizePositive, + normalizeStep, + resolveDirection, +} from "../src/internal/model"; + +describe("numeric rating model", () => { + it("normalizes unsafe configuration and values", () => { + expect({ + gap: normalizeGap(Number.NaN), + infiniteMax: normalizeMax(Number.POSITIVE_INFINITY), + largeMax: normalizeMax(1000), + largeSize: normalizePositive(Number.MAX_VALUE, 28), + negativeSize: normalizePositive(-1, 28), + oversizedGap: normalizeGap(Number.MAX_VALUE), + smallStep: normalizeStep(0), + }).toStrictEqual({ + gap: 0, + infiniteMax: 5, + largeMax: 100, + largeSize: MAX_LAYOUT_VALUE, + negativeSize: 28, + oversizedGap: MAX_LAYOUT_VALUE, + smallStep: 0.01, + }); + + const model = createNumericModel(3.9, 2.2, 0.5); + + expect(model).toStrictEqual({ + max: 3, + maxTick: 6, + minTick: 5, + step: 0.5, + ticksPerItem: 2, + }); + expect({ + aboveMax: normalizeNumericTick(99, model), + belowMin: normalizeNumericTick(1, model), + invalid: normalizeNumericTick(Number.NaN, model), + negative: normalizeNumericTick(-3, model), + }).toStrictEqual({ + aboveMax: 6, + belowMin: 5, + invalid: 0, + negative: 0, + }); + }); + + it.each([ + { + expected: [0, 0.6, 1, 1.6, 2], + max: 2, + step: 0.6, + }, + { + expected: [0, 0.25, 0.5, 0.75, 1], + max: 1, + step: 0.25, + }, + { + expected: [0, 0.3, 0.6, 0.9, 1], + max: 1, + step: 0.3, + }, + ])("keeps the per-item $step tick grid exact", ({ expected, max, step }) => { + const model = createNumericModel(max, 0, step); + + expect( + Array.from({ length: model.maxTick + 1 }, (_, tick) => + getValueFromTick(tick, model) + ) + ).toStrictEqual(expected); + }); + + it("keeps every hundredth-step lattice boundary stable", () => { + let checkedTicks = 0; + + for (let stepHundredths = 1; stepHundredths <= 100; stepHundredths += 1) { + const step = stepHundredths / 100; + const model = createNumericModel(1, 0, step); + + for (let tick = 0; tick <= model.maxTick; tick += 1) { + expect(getClosestTick(getValueFromTick(tick, model), model)).toBe(tick); + checkedTicks += 1; + } + } + + expect(checkedTicks).toBeGreaterThan(100); + }); + + it("does not overshoot exact hundredth minimums or pointer boundaries", () => { + const model = createNumericModel(1, 0.07, 0.01); + const positionModel = { + direction: "ltr" as const, + extent: 100, + gap: 0, + itemCount: 1, + itemSize: 100, + orientation: "horizontal" as const, + }; + + expect({ + minTick: model.minTick, + minValue: getValueFromTick(model.minTick, model), + pointerTick: getNumericTickFromPosition(7, positionModel, model), + }).toStrictEqual({ + minTick: 7, + minValue: 0.07, + pointerTick: 7, + }); + }); + + it("uses the exposed value lattice for arbitrary-precision boundaries", () => { + const irregularStep = 0.5993196205575954; + const irregularValue = 0.59932; + const irregularModel = createNumericModel(1, irregularValue, irregularStep); + const seventhModel = createNumericModel(1, 0, 1 / 7); + const nearQuarterModel = createNumericModel(1, 0, 0.2499999); + const positionModel = { + direction: "ltr" as const, + extent: 100_000, + gap: 0, + itemCount: 1, + itemSize: 100_000, + orientation: "horizontal" as const, + }; + + expect({ + irregularMinTick: irregularModel.minTick, + irregularPointerTick: getNumericTickFromPosition( + irregularValue * positionModel.extent, + positionModel, + irregularModel + ), + irregularTicks: irregularModel.ticksPerItem, + irregularValue: getValueFromTick(1, irregularModel), + nearQuarterTicks: nearQuarterModel.ticksPerItem, + seventhTicks: seventhModel.ticksPerItem, + seventhValue: getValueFromTick(7, seventhModel), + }).toStrictEqual({ + irregularMinTick: 1, + irregularPointerTick: 1, + irregularTicks: 2, + irregularValue, + nearQuarterTicks: 4, + seventhTicks: 7, + seventhValue: 1, + }); + }); + + it("renders exact aggregate display values unless snapping is requested", () => { + expect(normalizeDisplayValue(4.37, 5)).toBe(4.37); + expect(normalizeDisplayValue(4.37, 5, 0.5)).toBe(4.5); + expect(normalizeDisplayValue(Number.NaN, 5)).toBe(0); + expect(normalizeDisplayValue(8, 5)).toBe(5); + }); + + it("provides consistent increment, decrement, home, and item helpers", () => { + expect({ + clear: getDecrementTick(3, 3, true), + decrement: getDecrementTick(6, 3, true), + empty: getDecrementTick(0, 3, false), + floor: getDecrementTick(3, 3, false), + home: getHomeTick(3, false), + homeClear: getHomeTick(3, true), + hugeTrack: getTrackExtent( + Number.MAX_VALUE, + Number.MAX_VALUE, + Number.MAX_VALUE + ), + increment: getIncrementTick(0, 3, 10), + itemAtThree: getTickItemIndex(3, 2), + itemAtZero: getTickItemIndex(0, 2), + max: getIncrementTick(10, 3, 10), + track: getTrackExtent(5, 28, 4), + }).toStrictEqual({ + clear: 0, + decrement: 5, + empty: 0, + floor: 3, + home: 3, + homeClear: 0, + hugeTrack: MAX_LAYOUT_VALUE * (MAX_ITEMS * 2 - 1), + increment: 3, + itemAtThree: 1, + itemAtZero: -1, + max: 10, + track: 156, + }); + }); +}); + +describe("rating position model", () => { + const ratingModel = createNumericModel(3, 0, 0.5); + const basePositionModel = { + direction: "ltr" as const, + extent: 100, + gap: 5, + itemCount: 3, + itemSize: 30, + orientation: "horizontal" as const, + }; + + it("maps leading, fractional, gap, boundary, and trailing positions", () => { + expect( + [0, 7, 15, 30, 32, 34, 35, 50, 100].map((position) => + getNumericTickFromPosition(position, basePositionModel, ratingModel) + ) + ).toStrictEqual([1, 1, 1, 2, 2, 3, 3, 3, 6]); + }); + + it("uses locale direction horizontally and bottom-to-top progression vertically", () => { + expect( + getNumericTickFromPosition( + 10, + { ...basePositionModel, direction: "rtl" }, + ratingModel + ) + ).toBe(6); + expect( + getNumericTickFromPosition( + 10, + { + ...basePositionModel, + direction: "rtl", + orientation: "vertical", + }, + ratingModel + ) + ).toBe(6); + }); + + it("maps scale slots along the logical slider axis", () => { + const scaleModel = { + ...basePositionModel, + itemCount: 3, + }; + + expect(getScaleTickFromPosition(0, scaleModel)).toBe(1); + expect(getScaleTickFromPosition(100, scaleModel)).toBe(3); + expect( + getScaleTickFromPosition(10, { + ...scaleModel, + direction: "rtl", + }) + ).toBe(3); + expect( + getScaleTickFromPosition(10, { + ...scaleModel, + orientation: "vertical", + }) + ).toBe(3); + expect( + getScaleTickFromPosition(90, { + ...scaleModel, + orientation: "vertical", + }) + ).toBe(1); + }); + + it("resolves direction and fractional fill origins once", () => { + expect({ + autoLTR: resolveDirection("auto", false), + autoRTL: resolveDirection("auto", true), + explicit: resolveDirection("ltr", true), + horizontalLTR: getFillOrigin("horizontal", "ltr"), + horizontalRTL: getFillOrigin("horizontal", "rtl"), + vertical: getFillOrigin("vertical", "rtl"), + }).toStrictEqual({ + autoLTR: "ltr", + autoRTL: "rtl", + explicit: "ltr", + horizontalLTR: "left", + horizontalRTL: "right", + vertical: "bottom", + }); + }); +}); diff --git a/__tests__/rating-display.test.tsx b/__tests__/rating-display.test.tsx new file mode 100644 index 0000000..05f6c87 --- /dev/null +++ b/__tests__/rating-display.test.tsx @@ -0,0 +1,117 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { render, screen } from "@testing-library/react-native"; +import { AccessibilityInfo, Animated, Text, View } from "react-native"; + +import { RatingDisplay } from "../src"; + +const hidden = { includeHiddenElements: true }; + +describe("rating display", () => { + it("renders an exact aggregate without quantizing it", async () => { + await render( + ( + {fill} + )} + testID="display" + value={4.37} + /> + ); + + expect( + screen.getByRole("image", { name: "Rating" }) + ).toHaveAccessibilityValue({ text: "4.37 out of 5" }); + expect(screen.getByTestId("display-fill-3", hidden)).toHaveTextContent("1"); + expect(screen.getByTestId("display-fill-4", hidden)).toHaveTextContent( + "0.37" + ); + }); + + it("supports opt-in display snapping and vertical RTL renderer metadata", async () => { + await render( + ( + {`${orientation}:${fillOrigin}`} + )} + step={0.5} + testID="display" + value={2.26} + /> + ); + + expect(screen.getAllByText("vertical:bottom", hidden)).toHaveLength(5); + expect(screen.getByRole("image")).toHaveAccessibilityValue({ + text: "2.5 out of 5", + }); + expect(screen.getByTestId("display-control", hidden)).toHaveStyle({ + direction: "rtl", + flexDirection: "column-reverse", + height: 140, + width: 28, + }); + }); + + it("can be decorative when adjacent text already announces the value", async () => { + await render(); + + const display = screen.getByTestId("display", hidden); + expect(screen.queryByRole("image")).toBeNull(); + expect(display.props).toMatchObject({ + accessibilityElementsHidden: true, + accessible: false, + "aria-hidden": true, + importantForAccessibility: "no-hide-descendants", + }); + }); + + it("allocates no responders, animations, or motion listeners across a list", async () => { + const subscribe = jest.spyOn(AccessibilityInfo, "addEventListener"); + const timing = jest.spyOn(Animated, "timing"); + + await render( + + {Array.from({ length: 100 }, (_, index) => ( + + ))} + + ); + + expect( + screen.getAllByRole("image", { includeHiddenElements: true }) + ).toHaveLength(100); + expect( + screen.getByTestId("display-50-control", hidden).props + .onStartShouldSetResponder + ).toBeUndefined(); + expect(subscribe).not.toHaveBeenCalled(); + expect(timing).not.toHaveBeenCalled(); + }); + + it("clamps invalid values and dimensions safely", async () => { + await render( + + ); + + expect(screen.getByRole("image")).toHaveAccessibilityValue({ + text: "0 out of 5", + }); + expect(screen.getAllByTestId(/display-item-/u, hidden)).toHaveLength(5); + expect(screen.getByTestId("display-item-1", hidden)).toHaveStyle({ + height: 28, + width: 28, + }); + }); +}); diff --git a/__tests__/rating-scale.test.tsx b/__tests__/rating-scale.test.tsx new file mode 100644 index 0000000..e698089 --- /dev/null +++ b/__tests__/rating-scale.test.tsx @@ -0,0 +1,515 @@ +import { afterEach, describe, expect, it, jest } from "@jest/globals"; +import { act, fireEvent, render, screen } from "@testing-library/react-native"; +import { useId } from "react"; +import type { ReactNode } from "react"; +import { AccessibilityInfo, Animated, Text } from "react-native"; + +import { RatingScale } from "../src"; +import type { RatingScaleProps, RatingScaleRenderItemProps } from "../src"; + +const hidden = { includeHiddenElements: true }; +const sentimentItems = [ + { content: "😞", label: "Negative", value: -1 }, + { content: "😐", label: "Neutral", value: 0 }, + { content: "πŸ™‚", label: "Positive", value: 1 }, +] as const; + +const StatefulScaleItem = ({ value }: { value: number }) => { + const instance = useId(); + + return {`${value}:${instance}`}; +}; + +const renderStatefulScaleItem = ({ + value, +}: RatingScaleRenderItemProps) => ; + +const hasTransformStyle = (style: unknown): boolean => { + if (Array.isArray(style)) { + return style.some((entry) => hasTransformStyle(entry)); + } + + return ( + typeof style === "object" && + style !== null && + Object.hasOwn(style, "transform") + ); +}; + +const gestureEvent = ( + locationX: number, + pageX = locationX, + pageY = 20, + touches: readonly unknown[] = [{}] +) => ({ + nativeEvent: { + locationX, + locationY: 20, + pageX, + pageY, + touches, + }, +}); + +const tapScale = async (locationX: number): Promise => { + const control = screen.getByTestId("scale-control", hidden); + const event = gestureEvent(locationX); + await fireEvent(control, "responderGrant", event); + await fireEvent( + control, + "responderRelease", + gestureEvent(locationX, locationX, 20, []) + ); +}; + +describe("rating scale", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("keeps null empty while treating zero and negative values as real choices", async () => { + const onChange = jest.fn<(value: number | null) => void>(); + + await render( + + ); + + expect(screen.getByRole("adjustable")).toHaveAccessibilityValue({ + max: 3, + min: 0, + now: 0, + text: "No selection", + }); + + await tapScale(40); + expect(onChange).toHaveBeenCalledWith(0); + expect(screen.getByRole("adjustable")).toHaveAccessibilityValue({ + now: 2, + text: "Neutral", + }); + + await tapScale(5); + expect(onChange).toHaveBeenLastCalledWith(-1); + }); + + it("does not turn an empty scale into its first choice on decrement", async () => { + const onChange = jest.fn<(value: number | null) => void>(); + + await render( + + ); + + const scale = screen.getByRole("adjustable"); + await fireEvent(scale, "accessibilityAction", { + nativeEvent: { actionName: "decrement" }, + }); + + expect(onChange).not.toHaveBeenCalled(); + expect(scale).toHaveAccessibilityValue({ + min: 0, + now: 0, + text: "No selection", + }); + }); + + it("clears a selected semantic value without conflating it with numeric zero", async () => { + const onChange = jest.fn<(value: number | null) => void>(); + + await render( + + ); + + await tapScale(40); + expect(onChange).toHaveBeenCalledWith(null); + + const scale = screen.getByRole("adjustable"); + await fireEvent(scale, "accessibilityAction", { + nativeEvent: { actionName: "increment" }, + }); + expect(onChange).toHaveBeenLastCalledWith(-1); + await fireEvent(scale, "accessibilityAction", { + nativeEvent: { actionName: "decrement" }, + }); + expect(onChange).toHaveBeenLastCalledWith(null); + }); + + it("supports semantic reversal independently of RTL layout", async () => { + const onChange = jest.fn<(value: number | null) => void>(); + + await render( + ( + {`${direction}:${value}`} + )} + reversed + testID="scale" + /> + ); + + expect(screen.getByTestId("semantic-2", hidden)).toHaveTextContent("rtl:1"); + expect(screen.getByTestId("scale-item-3", hidden)).toHaveTextContent( + "rtl:1" + ); + + await tapScale(5); + expect(onChange).toHaveBeenCalledWith(-1); + }); + + it("supports single and cumulative selection visuals", async () => { + const view = await render( + ( + {String(selected)} + )} + selectionMode="single" + testID="scale" + value={0} + /> + ); + + expect(screen.getByTestId("selected-0", hidden)).toHaveTextContent("false"); + expect(screen.getByTestId("selected-1", hidden)).toHaveTextContent("true"); + + await view.rerender( + ( + {String(selected)} + )} + selectionMode="cumulative" + testID="scale" + value={0} + /> + ); + expect(screen.getByTestId("selected-0", hidden)).toHaveTextContent("true"); + expect(screen.getByTestId("selected-1", hidden)).toHaveTextContent("true"); + expect(screen.getByTestId("selected-2", hidden)).toHaveTextContent("false"); + }); + + it("supports readable label extents and gives custom renderers visual ownership", async () => { + const labelItems = [ + { content: null, label: "Strongly disagree", value: "strongly-disagree" }, + ] as const; + const view = await render( + + ); + + expect(screen.getByText("Strongly disagree", hidden)).toBeOnTheScreen(); + expect(screen.getByTestId("scale-item-1", hidden)).toHaveStyle({ + width: 140, + }); + + await view.rerender( + {label}} + testID="scale" + value="strongly-disagree" + /> + ); + expect(screen.getByTestId("scale-item-1", hidden)).not.toHaveStyle({ + borderWidth: 2, + }); + const control = screen.getByTestId("scale-control", hidden); + await fireEvent(control, "responderGrant", gestureEvent(10)); + expect(screen.getByTestId("scale-item-1", hidden)).not.toHaveStyle({ + transform: [{ scale: 0.94 }], + }); + await fireEvent( + control, + "responderTerminate", + gestureEvent(10, 10, 20, []) + ); + + await view.rerender( + + ); + expect(screen.getByTestId("scale-item-1", hidden)).toHaveStyle({ + width: 28, + }); + }); + + it("can hide a read-only scale when adjacent content is equivalent", async () => { + await render( + + ); + + const scale = screen.getByTestId("scale", hidden); + expect(screen.queryByRole("image")).toBeNull(); + expect(scale.props).toMatchObject({ + accessibilityElementsHidden: true, + accessible: false, + "aria-hidden": true, + importantForAccessibility: "no-hide-descendants", + }); + }); + + it("uses a local draft for deduplicated semantic dragging", async () => { + const onChange = jest.fn<(value: number | null) => void>(); + const onChangeEnd = + jest.fn["onChangeEnd"]>>(); + const onInteractionStart = + jest.fn["onInteractionStart"]>>(); + + await render( + + ); + const control = screen.getByTestId("scale-control", hidden); + + await fireEvent(control, "responderGrant", gestureEvent(5, 105, 50)); + await fireEvent(control, "responderMove", gestureEvent(500, 145, 50)); + await fireEvent(control, "responderMove", gestureEvent(-20, 145, 50)); + await fireEvent(control, "responderMove", gestureEvent(-20, 180, 50)); + + expect(onInteractionStart).toHaveBeenCalledWith(-1, { + source: "pointer", + }); + expect(onChange.mock.calls).toStrictEqual([[0], [1]]); + expect(screen.getByRole("adjustable")).toHaveAccessibilityValue({ + now: 3, + text: "Positive", + }); + + await fireEvent( + control, + "responderRelease", + gestureEvent(-20, 180, 50, []) + ); + expect(onChangeEnd).toHaveBeenCalledWith(1, { + cancelled: false, + source: "pointer", + }); + }); + + it("normalizes removed, duplicate, and invalid choices permanently", async () => { + const view = await render( + + animated={false} + defaultValue="b" + items={[ + { label: "A", value: "a" }, + { label: "B", value: "b" }, + ]} + testID="scale" + /> + ); + expect(screen.getByRole("adjustable")).toHaveAccessibilityValue({ + now: 2, + text: "B", + }); + + await view.rerender( + + animated={false} + items={[ + { label: "A", value: "a" }, + { label: "Duplicate", value: "a" }, + { label: "Invalid", value: Number.NaN }, + { label: " ", value: "blank" }, + ]} + testID="scale" + /> + ); + expect(screen.getByRole("adjustable")).toHaveAccessibilityValue({ + max: 1, + now: 0, + text: "No selection", + }); + + await view.rerender( + + animated={false} + items={[ + { label: "A", value: "a" }, + { label: "B", value: "b" }, + ]} + testID="scale" + /> + ); + expect(screen.getByRole("adjustable")).toHaveAccessibilityValue({ + now: 0, + text: "No selection", + }); + }); + + it("memoizes untouched semantic items during drag previews", async () => { + const items = Array.from({ length: 100 }, (_, value) => ({ + label: `Choice ${value}`, + value, + })); + const renderItem = jest.fn< + (props: RatingScaleRenderItemProps) => ReactNode + >(({ index }) => {index}); + + await render( + + ); + const control = screen.getByTestId("scale-control", hidden); + + await fireEvent(control, "responderGrant", gestureEvent(5, 105, 50)); + await fireEvent(control, "responderMove", gestureEvent(25, 125, 50)); + + expect( + renderItem.mock.calls.filter(([props]) => props.index === 99) + ).toHaveLength(1); + }); + + it("preserves custom item state when semantic values are reordered", async () => { + const initialItems = [ + { label: "One", value: 1 }, + { label: "Two", value: 2 }, + { label: "Three", value: 3 }, + ] as const; + const reorderedItems = [ + { label: "Three", value: 3 }, + { label: "Two", value: 2 }, + { label: "One", value: 1 }, + ] as const; + const view = await render( + + ); + const before = initialItems.map(({ value }) => + String(screen.getByTestId(`stateful-${value}`, hidden).props.children) + ); + + await view.rerender( + + ); + + const after = initialItems.map(({ value }) => + String(screen.getByTestId(`stateful-${value}`, hidden).props.children) + ); + expect(after).toStrictEqual(before); + }); + + it("uses a responder-free static path for read-only or empty scales", async () => { + const subscribe = jest.spyOn(AccessibilityInfo, "addEventListener"); + const timing = jest.spyOn(Animated, "timing"); + const view = await render( + + ); + + expect( + screen.getByRole("image", { name: "Rating scale" }) + ).toHaveAccessibilityValue({ text: "Positive" }); + expect( + screen.getByTestId("scale-control", hidden).props + .onStartShouldSetResponder + ).toBeUndefined(); + expect(subscribe).not.toHaveBeenCalled(); + expect(timing).not.toHaveBeenCalled(); + + await view.rerender(); + expect(screen.getByRole("image")).toHaveAccessibilityValue({ + text: "No selection", + }); + }); + + it("stops an in-flight pulse when semantic item order changes", async () => { + const originalTiming = Animated.timing; + jest + .spyOn(AccessibilityInfo, "isReduceMotionEnabled") + .mockResolvedValue(false); + jest + .spyOn(AccessibilityInfo, "addEventListener") + .mockReturnValue({ remove: jest.fn<() => void>() }); + jest.spyOn(Animated, "timing").mockImplementation((scale, config) => ({ + ...originalTiming(scale, config), + start: jest.fn<() => void>(), + stop: jest.fn<() => void>(), + })); + const view = await render( + + ); + await act(async () => { + await Promise.resolve(); + }); + + await tapScale(5); + const pulseWasVisible = hasTransformStyle( + screen.getByTestId("scale-item-1", hidden).props.style as unknown + ); + + await view.rerender( + + ); + const pulseWasCleared = !hasTransformStyle( + screen.getByTestId("scale-item-1", hidden).props.style as unknown + ); + await view.unmount(); + expect({ pulseWasCleared, pulseWasVisible }).toStrictEqual({ + pulseWasCleared: true, + pulseWasVisible: true, + }); + }); +}); diff --git a/__tests__/rating.test.tsx b/__tests__/rating.test.tsx index dad97ed..0cd1252 100644 --- a/__tests__/rating.test.tsx +++ b/__tests__/rating.test.tsx @@ -1,74 +1,165 @@ import { afterEach, describe, expect, it, jest } from "@jest/globals"; -import { - act, - fireEvent, - render, - screen, - userEvent, -} from "@testing-library/react-native"; +import { fireEvent, render, screen } from "@testing-library/react-native"; import { AccessibilityInfo, Animated, I18nManager, Text } from "react-native"; import { Rating } from "../src"; +import type { RatingProps } from "../src"; + +const hidden = { includeHiddenElements: true }; + +const responderEvent = ({ + locationX, + locationY = 20, + pageX = locationX, + pageY = locationY, + touches = [{}], +}: { + locationX: number; + locationY?: number; + pageX?: number; + pageY?: number; + touches?: readonly unknown[]; +}) => ({ + nativeEvent: { + locationX, + locationY, + pageX, + pageY, + touches, + }, +}); + +const tapTrack = async ( + testID: string, + locationX: number, + locationY = 20 +): Promise => { + const control = screen.getByTestId(`${testID}-control`, hidden); + const event = responderEvent({ locationX, locationY }); + await fireEvent(control, "responderGrant", event); + await fireEvent(control, "responderRelease", { + ...event, + nativeEvent: { ...event.nativeEvent, touches: [] }, + }); +}; describe("rating", () => { afterEach(() => { jest.restoreAllMocks(); }); - it("exposes a concise adjustable accessibility control", async () => { + it("exposes one adjustable control and hides decorative descendants", async () => { await render(); const rating = screen.getByRole("adjustable", { name: "Rating" }); + const control = screen.getByTestId("rating-control", hidden); expect(rating).toHaveAccessibilityValue({ max: 5, - min: 0, + min: 1, now: 2, text: "2 out of 5", }); - expect(screen.getAllByTestId(/rating-item-/u)).toHaveLength(5); + expect(control).toHaveProp("accessibilityElementsHidden", true); + expect(control).toHaveProp( + "importantForAccessibility", + "no-hide-descendants" + ); + expect(screen.getAllByTestId(/rating-item-/u, hidden)).toHaveLength(5); + expect(screen.queryByText("β˜…")).toBeNull(); }); - it("updates an uncontrolled value after a press", async () => { + it("keeps runtime root semantics and responder ownership authoritative", async () => { const onChange = jest.fn<(value: number) => void>(); - const user = userEvent.setup(); + const hostileRootProps = { + accessible: false, + "aria-hidden": true, + onKeyDown: jest.fn(), + onStartShouldSetResponderCapture: jest.fn(() => true), + pointerEvents: "none" as const, + }; await render( - + ); - await user.press(screen.getByTestId("rating-item-3")); + const rating = screen.getByTestId("rating"); + expect(rating.props).toMatchObject({ + accessibilityRole: "adjustable", + accessible: true, + }); + expect(rating.props).not.toHaveProperty("aria-hidden"); + expect(rating.props).not.toHaveProperty("onStartShouldSetResponderCapture"); + expect(rating.props).not.toHaveProperty("pointerEvents"); + + await tapTrack("rating", 70); expect(onChange).toHaveBeenCalledWith(3); - expect(screen.getByRole("adjustable")).toHaveAccessibilityValue({ now: 3 }); }); - it("waits for a controlled parent to update the value", async () => { + it("updates uncontrolled state after a root-track tap", async () => { const onChange = jest.fn<(value: number) => void>(); - const user = userEvent.setup(); + const onInteractionStart = + jest.fn>(); + const onChangeEnd = jest.fn>(); + + await render( + + ); + await tapTrack("rating", 70); + expect(onInteractionStart).toHaveBeenCalledWith(0, { + source: "pointer", + }); + expect(onChange).toHaveBeenCalledWith(3); + expect(onChangeEnd).toHaveBeenCalledWith(3, { + cancelled: false, + source: "pointer", + }); + expect(screen.getByRole("adjustable")).toHaveAccessibilityValue({ + now: 3, + }); + }); + + it("keeps controlled state owned by the parent", async () => { + const onChange = jest.fn<(value: number) => void>(); const view = await render( ); - await user.press(screen.getByTestId("rating-item-4")); + await tapTrack("rating", 100); expect(onChange).toHaveBeenCalledWith(4); - expect(screen.getByRole("adjustable")).toHaveAccessibilityValue({ now: 1 }); + expect(screen.getByRole("adjustable")).toHaveAccessibilityValue({ + now: 1, + }); await view.rerender( ); - expect(screen.getByRole("adjustable")).toHaveAccessibilityValue({ now: 4 }); + expect(screen.getByRole("adjustable")).toHaveAccessibilityValue({ + now: 4, + }); }); - it("supports fractional steps and exposes item fill to custom renderers", async () => { + it("uses one exact fractional value for callback, fill, and accessibility", async () => { const onChange = jest.fn<(value: number) => void>(); await render( ( - {fill} + renderItem={({ fill, fillOrigin, index }) => ( + {`${fill}:${fillOrigin}`} )} size={40} step={0.5} @@ -77,53 +168,46 @@ describe("rating", () => { /> ); - expect(screen.getByTestId("fill-0")).toHaveTextContent("1"); - expect(screen.getByTestId("fill-1")).toHaveTextContent("0.5"); - expect(screen.getByTestId("fill-2")).toHaveTextContent("0"); + expect(screen.getByTestId("fill-0", hidden)).toHaveTextContent("1:left"); + expect(screen.getByTestId("fill-1", hidden)).toHaveTextContent("0.5:left"); - await fireEvent.press(screen.getByTestId("rating-item-3"), { - nativeEvent: { locationX: 8 }, - }); + await tapTrack("rating", 90); expect(onChange).toHaveBeenCalledWith(2.5); }); - it("mirrors fractional fill and press position in right-to-left layouts", async () => { - const onChange = jest.fn<(value: number) => void>(); - - jest.replaceProperty(I18nManager, "isRTL", true); - + it("gives custom renderers ownership of pressed visuals", async () => { await render( ( - {fill} + renderItem={({ index, pressed }) => ( + {String(pressed)} )} - size={40} - step={0.5} testID="rating" - value={1.5} /> ); + const control = screen.getByTestId("rating-control", hidden); - expect(screen.getByTestId("rating")).toHaveStyle({ - flexDirection: "row-reverse", - }); - expect(screen.getByTestId("rtl-fill-1")).toHaveTextContent("0.5"); + await fireEvent( + control, + "responderGrant", + responderEvent({ locationX: 10 }) + ); - await fireEvent.press(screen.getByTestId("rating-item-3"), { - nativeEvent: { locationX: 4 }, - }); - await fireEvent.press(screen.getByTestId("rating-item-3"), { - nativeEvent: { locationX: 40 }, + expect(screen.getByTestId("pressed-0", hidden)).toHaveTextContent("true"); + expect(screen.getByTestId("rating-item-1", hidden)).not.toHaveStyle({ + transform: [{ scale: 0.94 }], }); - expect(onChange).toHaveBeenNthCalledWith(1, 3); - expect(onChange).toHaveBeenNthCalledWith(2, 2.5); + await fireEvent(control, "responderTerminate", { + nativeEvent: { + ...responderEvent({ locationX: 10 }).nativeEvent, + touches: [], + }, + }); + expect(screen.getByTestId("pressed-0", hidden)).toHaveTextContent("false"); }); - it("keeps non-divisor steps inside the pressed item", async () => { + it("preserves non-divisor per-item steps", async () => { const onChange = jest.fn<(value: number) => void>(); await render( @@ -133,250 +217,800 @@ describe("rating", () => { size={48} step={0.6} testID="rating" - value={0} /> ); - await fireEvent.press(screen.getByTestId("rating-item-1"), { - nativeEvent: { locationX: 24 }, - }); - await fireEvent.press(screen.getByTestId("rating-item-1"), { - nativeEvent: { locationX: 48 }, - }); + await tapTrack("rating", 20); + await tapTrack("rating", 48); expect(onChange).toHaveBeenNthCalledWith(1, 0.6); expect(onChange).toHaveBeenNthCalledWith(2, 1); }); - it("uses integer accessibility ticks for fractional values", async () => { - await render(); + it("enforces min while preserving zero as the unrated sentinel", async () => { + const onChange = jest.fn<(value: number) => void>(); - expect(screen.getByRole("adjustable")).toHaveAccessibilityValue({ - max: 10, + await render( + + ); + + await tapTrack("rating", 2); + expect(onChange).toHaveBeenLastCalledWith(3); + + const rating = screen.getByRole("adjustable"); + await fireEvent(rating, "accessibilityAction", { + nativeEvent: { actionName: "decrement" }, + }); + expect(onChange).toHaveBeenLastCalledWith(0); + + await fireEvent(rating, "accessibilityAction", { + nativeEvent: { actionName: "increment" }, + }); + expect(onChange).toHaveBeenLastCalledWith(3); + }); + + it("keeps an empty rating empty when decrement cannot clear", async () => { + const onChange = jest.fn<(value: number) => void>(); + + await render( + + ); + + const rating = screen.getByRole("adjustable"); + await fireEvent(rating, "accessibilityAction", { + nativeEvent: { actionName: "decrement" }, + }); + + expect(onChange).not.toHaveBeenCalled(); + expect(rating).toHaveAccessibilityValue({ + max: 5, min: 0, - now: 3, - text: "1.5 out of 5", + now: 0, + text: "0 out of 5", }); }); - it("can clear the selected value", async () => { + it("clears only a true same-value tap", async () => { const onChange = jest.fn<(value: number) => void>(); - const user = userEvent.setup(); await render( ); - await user.press(screen.getByTestId("rating-item-3")); + await tapTrack("rating", 70); expect(onChange).toHaveBeenCalledWith(0); - expect(screen.getByRole("adjustable")).toHaveAccessibilityValue({ now: 0 }); + + await tapTrack("rating", 70); + const control = screen.getByTestId("rating-control", hidden); + await fireEvent( + control, + "responderGrant", + responderEvent({ locationX: 10, pageX: 110, pageY: 50 }) + ); + await fireEvent( + control, + "responderMove", + responderEvent({ locationX: 10, pageX: 180, pageY: 51 }) + ); + await fireEvent( + control, + "responderMove", + responderEvent({ locationX: 10, pageX: 110, pageY: 50 }) + ); + await fireEvent( + control, + "responderRelease", + responderEvent({ + locationX: 10, + pageX: 110, + pageY: 50, + touches: [], + }) + ); + + expect(onChange.mock.calls.at(-1)?.[0]).toBe(1); }); - it("does not clear from an accessibility increment at the maximum", async () => { + it("tracks a deliberate drag with stable page coordinates and no duplicates", async () => { const onChange = jest.fn<(value: number) => void>(); + const onInteractionStart = + jest.fn>(); + const onChangeEnd = jest.fn>(); await render( ); - const rating = screen.getByRole("adjustable"); + const control = screen.getByTestId("rating-control", hidden); - await fireEvent(rating, "accessibilityAction", { - nativeEvent: { actionName: "increment" }, + await fireEvent( + control, + "responderGrant", + responderEvent({ locationX: 10, pageX: 110, pageY: 50 }) + ); + await fireEvent( + control, + "responderMove", + responderEvent({ locationX: 999, pageX: 175, pageY: 51 }) + ); + await fireEvent( + control, + "responderMove", + responderEvent({ locationX: -50, pageX: 175, pageY: 51 }) + ); + await fireEvent( + control, + "responderMove", + responderEvent({ locationX: -50, pageX: 205, pageY: 51 }) + ); + + expect(onInteractionStart).toHaveBeenCalledTimes(1); + expect(onInteractionStart).toHaveBeenCalledWith(1, { + source: "pointer", + }); + expect(onChange.mock.calls).toStrictEqual([[3], [4]]); + expect(screen.getByRole("adjustable")).toHaveAccessibilityValue({ + now: 4, }); - expect(onChange).not.toHaveBeenCalled(); - expect(rating).toHaveAccessibilityValue({ now: 10 }); + await fireEvent( + control, + "responderRelease", + responderEvent({ + locationX: -50, + pageX: 205, + pageY: 51, + touches: [], + }) + ); + expect(onChangeEnd).toHaveBeenCalledWith(4, { + cancelled: false, + source: "pointer", + }); }); - it("ignores touch and accessibility actions while disabled", async () => { + it("yields cross-axis scrolling before accepting an interaction", async () => { const onChange = jest.fn<(value: number) => void>(); - const user = userEvent.setup(); + const onInteractionStart = + jest.fn>(); + const onChangeEnd = jest.fn>(); await render( ); - const rating = screen.getByRole("adjustable"); + const control = screen.getByTestId("rating-control", hidden); - await user.press(screen.getByTestId("rating-item-5")); - await fireEvent(rating, "accessibilityAction", { - nativeEvent: { actionName: "increment" }, - }); + await fireEvent( + control, + "responderGrant", + responderEvent({ locationX: 10, pageX: 100, pageY: 100 }) + ); + await fireEvent( + control, + "responderMove", + responderEvent({ locationX: 10, pageX: 102, pageY: 130 }) + ); + await fireEvent(control, "responderTerminate"); expect(onChange).not.toHaveBeenCalled(); - expect(rating).toBeDisabled(); - expect(rating.props.accessibilityActions).toBeUndefined(); - expect(rating.props.onAccessibilityAction).toBeUndefined(); + expect(onInteractionStart).not.toHaveBeenCalled(); + expect(onChangeEnd).not.toHaveBeenCalled(); }); - it("presents read-only ratings as static content", async () => { + it("reports a system-terminated accepted drag without replaying values", async () => { const onChange = jest.fn<(value: number) => void>(); - const user = userEvent.setup(); + const onChangeEnd = jest.fn>(); await render( + ); + const control = screen.getByTestId("rating-control", hidden); + + await fireEvent( + control, + "responderGrant", + responderEvent({ locationX: 5, pageX: 105, pageY: 50 }) + ); + await fireEvent( + control, + "responderMove", + responderEvent({ locationX: 5, pageX: 175, pageY: 50 }) + ); + + await fireEvent(control, "responderTerminate"); + expect(onChange).toHaveBeenCalledWith(3); + expect(onChangeEnd).toHaveBeenCalledWith(3, { + cancelled: true, + source: "pointer", + }); + }); + + it("finalizes an accepted drag when the interactive path unmounts", async () => { + const onChangeEnd = jest.fn>(); + const onInteractionStart = + jest.fn>(); + const view = await render( + + ); + const control = screen.getByTestId("rating-control", hidden); + + await fireEvent( + control, + "responderGrant", + responderEvent({ locationX: 5, pageX: 105, pageY: 50 }) + ); + await fireEvent( + control, + "responderMove", + responderEvent({ locationX: 5, pageX: 175, pageY: 50 }) + ); + await view.rerender( + ); + await view.unmount(); - expect( - screen.getByRole("image", { name: "Rating" }) - ).toHaveAccessibilityValue({ now: 4 }); - expect(screen.getByTestId("rating-item-2")).toHaveStyle({ - height: 28, - width: 28, + expect(onInteractionStart).toHaveBeenCalledTimes(1); + expect(onChangeEnd.mock.calls).toStrictEqual([ + [3, { cancelled: true, source: "pointer" }], + ]); + }); + + it("cancels an accepted drag exactly once when a second pointer arrives", async () => { + const onChange = jest.fn<(value: number) => void>(); + const onChangeEnd = jest.fn>(); + + await render( + + ); + const control = screen.getByTestId("rating-control", hidden); + + await fireEvent( + control, + "responderGrant", + responderEvent({ locationX: 5, pageX: 105, pageY: 50 }) + ); + await fireEvent( + control, + "responderMove", + responderEvent({ locationX: 5, pageX: 175, pageY: 50 }) + ); + await fireEvent( + control, + "responderStart", + responderEvent({ + locationX: 5, + pageX: 180, + pageY: 50, + touches: [{}, {}], + }) + ); + await fireEvent( + control, + "responderEnd", + responderEvent({ + locationX: 5, + pageX: 180, + pageY: 50, + touches: [{}], + }) + ); + await fireEvent( + control, + "responderRelease", + responderEvent({ + locationX: 5, + pageX: 180, + pageY: 50, + touches: [], + }) + ); + + expect(onChange.mock.calls).toStrictEqual([[3]]); + expect(onChangeEnd).toHaveBeenCalledTimes(1); + expect(onChangeEnd).toHaveBeenCalledWith(3, { + cancelled: true, + source: "pointer", }); - await user.press(screen.getByTestId("rating-item-2")); - expect(onChange).not.toHaveBeenCalled(); }); - it("supports increment and decrement accessibility actions", async () => { + it("does not turn a two-pointer press into a tap after a partial lift", async () => { const onChange = jest.fn<(value: number) => void>(); + const onChangeEnd = jest.fn>(); + const onInteractionStart = + jest.fn>(); await render( + ); + const control = screen.getByTestId("rating-control", hidden); + + await fireEvent( + control, + "responderGrant", + responderEvent({ locationX: 5, pageX: 105, pageY: 50 }) + ); + await fireEvent( + control, + "responderStart", + responderEvent({ + locationX: 5, + pageX: 105, + pageY: 50, + touches: [{}, {}], + }) + ); + await fireEvent( + control, + "responderEnd", + responderEvent({ + locationX: 5, + pageX: 105, + pageY: 50, + touches: [{}], + }) + ); + await fireEvent( + control, + "responderRelease", + responderEvent({ + locationX: 100, + pageX: 200, + pageY: 50, + touches: [], + }) + ); + + expect({ + changes: onChange.mock.calls, + ends: onChangeEnd.mock.calls, + starts: onInteractionStart.mock.calls, + }).toStrictEqual({ changes: [], ends: [], starts: [] }); + }); + + it("cancels a live drag when disabled changes", async () => { + const onChange = jest.fn<(value: number) => void>(); + const onChangeEnd = jest.fn>(); + const view = await render( + + ); + const control = screen.getByTestId("rating-control", hidden); + + await fireEvent( + control, + "responderGrant", + responderEvent({ locationX: 5, pageX: 105, pageY: 50 }) + ); + await fireEvent( + control, + "responderMove", + responderEvent({ locationX: 5, pageX: 175, pageY: 50 }) + ); + await view.rerender( + + ); + await fireEvent( + control, + "responderMove", + responderEvent({ locationX: 5, pageX: 210, pageY: 50 }) + ); + await fireEvent( + control, + "responderRelease", + responderEvent({ + locationX: 5, + pageX: 210, + pageY: 50, + touches: [], + }) + ); + + expect(onChange.mock.calls).toStrictEqual([[3]]); + expect(onChangeEnd.mock.calls).toStrictEqual([ + [3, { cancelled: true, source: "pointer" }], + ]); + expect( + screen.getByTestId("rating-control", hidden).props.pointerEvents + ).toBe("none"); + }); + + it("cancels old geometry without decoding it through new structure", async () => { + const onChange = jest.fn<(value: number) => void>(); + const onChangeEnd = jest.fn>(); + const view = await render( + + ); + const control = screen.getByTestId("rating-control", hidden); + + await fireEvent( + control, + "responderGrant", + responderEvent({ locationX: 5, pageX: 105, pageY: 50 }) + ); + await fireEvent( + control, + "responderMove", + responderEvent({ locationX: 5, pageX: 175, pageY: 50 }) + ); + await view.rerender( + ); - const rating = screen.getByRole("adjustable"); + await fireEvent( + control, + "responderRelease", + responderEvent({ + locationX: 5, + pageX: 205, + pageY: 50, + touches: [], + }) + ); - await fireEvent(rating, "accessibilityAction", { - nativeEvent: { actionName: "increment" }, - }); - expect(onChange).toHaveBeenLastCalledWith(2.5); + expect(onChange.mock.calls).toStrictEqual([[3]]); + expect(onChangeEnd.mock.calls).toStrictEqual([ + [3, { cancelled: true, source: "pointer" }], + ]); + }); - await fireEvent(rating, "accessibilityAction", { - nativeEvent: { actionName: "decrement" }, + it("rejects a far tap release when no move event was delivered", async () => { + const onChange = jest.fn<(value: number) => void>(); + const onChangeEnd = jest.fn>(); + const onInteractionStart = + jest.fn>(); + + await render( + + ); + const control = screen.getByTestId("rating-control", hidden); + + await fireEvent( + control, + "responderGrant", + responderEvent({ locationX: 5, pageX: 105, pageY: 50 }) + ); + await fireEvent( + control, + "responderRelease", + responderEvent({ + locationX: 100, + pageX: 200, + pageY: 50, + touches: [], + }) + ); + + expect({ + changes: onChange.mock.calls, + ends: onChangeEnd.mock.calls, + starts: onInteractionStart.mock.calls, + }).toStrictEqual({ changes: [], ends: [], starts: [] }); + }); + + it("promotes a primary-axis far release to a drag when enabled", async () => { + const onChange = jest.fn<(value: number) => void>(); + + await render( + + ); + const control = screen.getByTestId("rating-control", hidden); + + await fireEvent( + control, + "responderGrant", + responderEvent({ locationX: 5, pageX: 105, pageY: 50 }) + ); + await fireEvent( + control, + "responderRelease", + responderEvent({ + locationX: 100, + pageX: 200, + pageY: 50, + touches: [], + }) + ); + + expect(onChange).toHaveBeenCalledWith(4); + }); + + it("uses the latest callbacks and configuration without rebuilding responders", async () => { + const firstOnChange = jest.fn<(value: number) => void>(); + const latestOnChange = jest.fn<(value: number) => void>(); + const view = await render( + + ); + + await view.rerender( + + ); + await tapTrack("rating", 70); + + expect(firstOnChange).not.toHaveBeenCalled(); + expect(latestOnChange).toHaveBeenCalledWith(3); + expect(screen.getByRole("adjustable")).toHaveAccessibilityValue({ + max: 3, + now: 3, }); - expect(onChange).toHaveBeenLastCalledWith(2); }); - it("honors reduced-motion changes and cleans up animation resources", async () => { - const animationStart = - jest.fn["start"]>(); - const animationStop = jest.fn["stop"]>(); - const removeSubscription = jest.fn<() => void>(); - const originalTiming = Animated.timing; - jest - .spyOn(AccessibilityInfo, "isReduceMotionEnabled") - .mockResolvedValue(false); - const subscribe = jest - .spyOn(AccessibilityInfo, "addEventListener") - .mockReturnValue({ remove: removeSubscription }); - const timing = jest - .spyOn(Animated, "timing") - .mockImplementation((value, config) => ({ - ...originalTiming(value, config), - start: animationStart, - stop: animationStop, - })); - - subscribe.mockClear(); - timing.mockClear(); - - const view = await render(); - - expect(subscribe).toHaveBeenCalledWith( - "reduceMotionChanged", - expect.any(Function) - ); - - const reduceMotionListener = subscribe.mock.calls[0]?.[1]; - - expect(reduceMotionListener).toBeDefined(); - await act(() => { - reduceMotionListener?.(false); + it("updates fallback geometry before the next native layout event", async () => { + const onChange = jest.fn<(value: number) => void>(); + const view = await render( + + ); + + await view.rerender( + + ); + await tapTrack("rating", 200); + + expect(onChange).toHaveBeenCalledWith(4); + }); + + it("supports explicit RTL and vertical geometry", async () => { + const onRTLChange = jest.fn<(value: number) => void>(); + const onVerticalChange = jest.fn<(value: number) => void>(); + + jest.replaceProperty(I18nManager, "isRTL", false); + const view = await render( + ( + + {`${direction}:${fillOrigin}`} + + )} + testID="rating" + /> + ); + + expect(screen.getByTestId("direction-0", hidden)).toHaveTextContent( + "rtl:right" + ); + await tapTrack("rating", 5); + expect(onRTLChange).toHaveBeenCalledWith(5); + + await view.rerender( + ( + {fillOrigin} + )} + testID="rating" + /> + ); + await tapTrack("rating", 20, 70); + await tapTrack("rating", 20, 5); + await tapTrack("rating", 20, 135); + expect(onVerticalChange.mock.calls).toStrictEqual([[3], [5], [1]]); + expect(screen.getByTestId("vertical-0", hidden)).toHaveTextContent( + "bottom" + ); + expect(screen.getByTestId("rating-control", hidden)).toHaveStyle({ + flexDirection: "column-reverse", + height: 140, + width: 44, }); - await view.rerender(); - - expect(timing).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - duration: 160, - isInteraction: false, - toValue: 1, - useNativeDriver: true, - }) + }); + + it("normalizes configuration changes without reviving stale state", async () => { + const view = await render( + ); - expect(animationStart).toHaveBeenCalledTimes(1); - await view.unmount(); + await view.rerender(); + expect(screen.getByRole("adjustable")).toHaveAccessibilityValue({ + now: 3, + }); - expect({ - animationStops: animationStop.mock.calls.length, - subscriptionsRemoved: removeSubscription.mock.calls.length, - }).toStrictEqual({ - animationStops: 1, - subscriptionsRemoved: 1, + await view.rerender(); + expect(screen.getByRole("adjustable")).toHaveAccessibilityValue({ + now: 3, }); }); - it("forwards native view props and style to the root", async () => { - const onLayout = jest.fn(); + it("suppresses every interaction path while disabled", async () => { + const onChange = jest.fn<(value: number) => void>(); await render( ); + const rating = screen.getByRole("adjustable"); + const control = screen.getByTestId("rating-control", hidden); - const rating = screen.getByTestId("rating"); + expect(rating).toBeDisabled(); + expect(rating.props.accessibilityActions).toBeUndefined(); + expect(rating.props.onAccessibilityAction).toBeUndefined(); + expect(control).toHaveProp("pointerEvents", "none"); + await fireEvent(rating, "accessibilityAction", { + nativeEvent: { actionName: "increment" }, + }); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("delegates read-only ratings to the static allocation-light path", async () => { + const subscribe = jest.spyOn(AccessibilityInfo, "addEventListener"); + const timing = jest.spyOn(Animated, "timing"); + + await render(); + + expect( + screen.getByRole("image", { name: "Rating" }) + ).toHaveAccessibilityValue({ text: "4 out of 5" }); + expect( + screen.getByTestId("rating-control", hidden).props + .onStartShouldSetResponder + ).toBeUndefined(); + expect(screen.getByTestId("rating-item-2", hidden)).toHaveStyle({ + height: 28, + width: 28, + }); + expect(subscribe).not.toHaveBeenCalled(); + expect(timing).not.toHaveBeenCalled(); + }); - expect(rating).toHaveProp( - "accessibilityHint", - "Swipe up or down to change" + it("preserves disabled state through the read-only compatibility path", async () => { + await render( + ( + {String(disabled)} + )} + testID="rating" + value={3} + /> ); - expect(rating).toHaveProp("nativeID", "product-rating"); - expect(rating).toHaveProp("onLayout", onLayout); - expect(rating).toHaveProp("pointerEvents", "box-none"); - expect(rating).toHaveStyle({ marginTop: 12 }); + + expect(screen.getByRole("image")).toBeDisabled(); + expect(screen.getByTestId("disabled-0", hidden)).toHaveTextContent("true"); + expect(screen.getByTestId("rating")).toHaveStyle({ opacity: 0.45 }); }); - it("normalizes unsafe runtime values", async () => { + it("forwards native root props and keeps structural direction authoritative", async () => { + const onLayout = jest.fn(); + await render( ); - expect(screen.getAllByTestId(/rating-item-/u)).toHaveLength(3); - expect(screen.getByRole("adjustable")).toHaveAccessibilityValue({ - max: 3, - now: 3, + const rating = screen.getByTestId("rating"); + expect(rating).toHaveProp("accessibilityHint", "Adjust the review score"); + expect(rating).toHaveProp("onLayout", onLayout); + expect(rating).toHaveStyle({ + direction: "ltr", + marginTop: 7, + opacity: 0.2, }); }); }); diff --git a/__tests__/reduced-motion.test.tsx b/__tests__/reduced-motion.test.tsx new file mode 100644 index 0000000..e031a0a --- /dev/null +++ b/__tests__/reduced-motion.test.tsx @@ -0,0 +1,180 @@ +import { afterEach, describe, expect, it, jest } from "@jest/globals"; +import { act, fireEvent, render, screen } from "@testing-library/react-native"; +import { AccessibilityInfo, Animated, View } from "react-native"; + +import { Rating } from "../src"; + +const hidden = { includeHiddenElements: true }; + +const hasTransformStyle = (style: unknown): boolean => { + if (Array.isArray(style)) { + return style.some((entry) => hasTransformStyle(entry)); + } + + return ( + typeof style === "object" && + style !== null && + Object.hasOwn(style, "transform") + ); +}; + +const tap = async (testID: string, locationX: number): Promise => { + const control = screen.getByTestId(`${testID}-control`, hidden); + const nativeEvent = { + locationX, + locationY: 20, + pageX: locationX, + pageY: 20, + touches: [{}], + }; + await fireEvent(control, "responderGrant", { nativeEvent }); + await fireEvent(control, "responderRelease", { + nativeEvent: { ...nativeEvent, touches: [] }, + }); +}; + +describe("shared reduced-motion store", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("creates no subscription when animation feedback is disabled", async () => { + const subscribe = jest.spyOn(AccessibilityInfo, "addEventListener"); + + await render(); + await tap("rating", 70); + + expect(subscribe).not.toHaveBeenCalled(); + }); + + it("shares one native subscription and animates only accepted completions", async () => { + const remove = jest.fn<() => void>(); + const animationStart = jest.fn<() => void>(); + const animationStop = jest.fn<() => void>(); + const originalTiming = Animated.timing; + jest + .spyOn(AccessibilityInfo, "isReduceMotionEnabled") + .mockResolvedValue(false); + const subscribe = jest + .spyOn(AccessibilityInfo, "addEventListener") + .mockReturnValue({ remove }); + const timing = jest + .spyOn(Animated, "timing") + .mockImplementation((value, config) => ({ + ...originalTiming(value, config), + start: animationStart, + stop: animationStop, + })); + + const view = await render( + + + + + ); + await act(async () => { + await Promise.resolve(); + }); + + expect(subscribe.mock.calls).toStrictEqual([ + ["reduceMotionChanged", expect.any(Function)], + ]); + + await tap("first", 70); + expect(timing).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + duration: 150, + isInteraction: false, + toValue: 1, + useNativeDriver: true, + }) + ); + expect({ + animationStarts: animationStart.mock.calls.length, + timingCalls: timing.mock.calls.length, + }).toStrictEqual({ animationStarts: 1, timingCalls: 1 }); + const pulseWasVisible = hasTransformStyle( + screen.getByTestId("first-item-3", hidden).props.style as unknown + ); + + await view.rerender( + + + + + ); + await view.rerender( + + + + + ); + const pulseWasCleared = !hasTransformStyle( + screen.getByTestId("first-item-3", hidden).props.style as unknown + ); + + const listener = subscribe.mock.calls[0]?.[1]; + await act(() => { + if (listener) { + listener(true); + } + }); + await tap("second", 70); + + await view.unmount(); + expect({ + animationStops: animationStop.mock.calls.length, + pulseCleared: pulseWasCleared, + pulseStarted: pulseWasVisible, + removals: remove.mock.calls.length, + timingCalls: timing.mock.calls.length, + }).toStrictEqual({ + animationStops: 1, + pulseCleared: true, + pulseStarted: true, + removals: 1, + timingCalls: 1, + }); + }); + + it("returns to a conservative snapshot between mounted rating groups", async () => { + const { promise: pendingPreference, resolve: resolvePreference } = + Promise.withResolvers(); + const originalTiming = Animated.timing; + jest + .spyOn(AccessibilityInfo, "isReduceMotionEnabled") + .mockResolvedValueOnce(false) + .mockReturnValueOnce(pendingPreference); + jest + .spyOn(AccessibilityInfo, "addEventListener") + .mockReturnValue({ remove: jest.fn<() => void>() }); + const timing = jest + .spyOn(Animated, "timing") + .mockImplementation((value, config) => ({ + ...originalTiming(value, config), + start: jest.fn<() => void>(), + stop: jest.fn<() => void>(), + })); + + const first = await render(); + await act(async () => { + await Promise.resolve(); + }); + await tap("first", 70); + expect(timing).toHaveBeenCalledTimes(1); + await first.unmount(); + + timing.mockClear(); + const second = await render(); + await tap("second", 70); + expect(timing).not.toHaveBeenCalled(); + + await act(async () => { + resolvePreference(true); + await pendingPreference; + }); + await second.unmount(); + expect(timing).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/root-props.test.ts b/__tests__/root-props.test.ts new file mode 100644 index 0000000..4a1ece5 --- /dev/null +++ b/__tests__/root-props.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, jest } from "@jest/globals"; + +import { getForwardedRatingRootProps } from "../src/internal/root-props"; + +describe("rating root props", () => { + it("preserves the common safe-props object by identity", () => { + const safeProps = { + onFocus: jest.fn(), + style: { opacity: 0.8 }, + testID: "rating", + }; + + expect(getForwardedRatingRootProps(safeProps)).toBe(safeProps); + }); + + it("strips owned semantics and responders from broad runtime spreads", () => { + const safeFocus = jest.fn(); + const hostileProps = { + accessible: false, + "aria-hidden": true, + onFocus: safeFocus, + onKeyDown: jest.fn(), + onKeyDownCapture: jest.fn(), + onStartShouldSetResponderCapture: jest.fn(() => true), + pointerEvents: "none" as const, + testID: "rating", + }; + + expect(getForwardedRatingRootProps(hostileProps)).toStrictEqual({ + onFocus: safeFocus, + testID: "rating", + }); + }); +}); diff --git a/__tests__/web.test.tsx b/__tests__/web.test.tsx new file mode 100644 index 0000000..72e3eeb --- /dev/null +++ b/__tests__/web.test.tsx @@ -0,0 +1,540 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + jest, +} from "@jest/globals"; +import { act, fireEvent, render, screen } from "@testing-library/react-native"; +import { + AccessibilityInfo, + Animated, + Platform, + StyleSheet, + View, +} from "react-native"; + +import { Rating, RatingDisplay, RatingScale } from "../src"; +import type { RatingProps } from "../src"; + +const hidden = { includeHiddenElements: true }; +const originalPlatformOS = Platform.OS; +const sentimentItems = [ + { content: "😞", label: "Negative", value: -1 }, + { content: "😐", label: "Neutral", value: 0 }, + { content: "πŸ™‚", label: "Positive", value: 1 }, +] as const; + +const setPlatformOS = (os: typeof Platform.OS): void => { + Object.defineProperty(Platform, "OS", { + configurable: true, + value: os, + }); +}; + +const pressKey = async ( + key: string, + modifiers: { + altKey?: boolean; + ctrlKey?: boolean; + metaKey?: boolean; + } = {} +): Promise<{ + preventDefault: jest.Mock<() => void>; + stopPropagation: jest.Mock<() => void>; +}> => { + const preventDefault = jest.fn<() => void>(); + const stopPropagation = jest.fn<() => void>(); + + await fireEvent(screen.getByTestId("rating"), "keyDown", { + ...modifiers, + key, + preventDefault, + stopPropagation, + }); + + return { preventDefault, stopPropagation }; +}; + +const tap = async (testID: string, locationX: number): Promise => { + const control = screen.getByTestId(`${testID}-control`, hidden); + const nativeEvent = { + locationX, + locationY: 20, + pageX: locationX, + pageY: 20, + touches: [{}], + }; + + await fireEvent(control, "responderGrant", { nativeEvent }); + await fireEvent(control, "responderRelease", { + nativeEvent: { ...nativeEvent, touches: [] }, + }); +}; + +describe("web contract", () => { + beforeAll(() => { + setPlatformOS("web"); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + afterAll(() => { + setPlatformOS(originalPlatformOS); + }); + + it("exposes direct decimal ARIA slider values and orientation", async () => { + await render( + + ); + const slider = screen.getByTestId("rating"); + + expect(slider.props).toMatchObject({ + accessibilityRole: "adjustable", + "aria-disabled": false, + "aria-label": "Review score", + "aria-orientation": "vertical", + "aria-valuemax": 7, + "aria-valuemin": 0.5, + "aria-valuenow": 2.5, + "aria-valuetext": "2.5 out of 7", + role: "slider", + tabIndex: 0, + }); + expect(slider.props).not.toHaveProperty("accessibilityValue"); + }); + + it("exposes semantic scale positions and labels without losing zero", async () => { + await render( + + ); + + expect(screen.getByTestId("scale").props).toMatchObject({ + "aria-orientation": "vertical", + "aria-valuemax": 3, + "aria-valuemin": 1, + "aria-valuenow": 2, + "aria-valuetext": "Neutral", + role: "slider", + tabIndex: 0, + }); + }); + + it("keeps reversed semantic values on the logical vertical slider axis", async () => { + const onChange = jest.fn<(value: number | null) => void>(); + + await render( + + ); + + const slider = screen.getByTestId("scale"); + + const keyboardEvent = { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }; + await fireEvent(slider, "keyDown", { + ...keyboardEvent, + key: "ArrowUp", + }); + await fireEvent(slider, "keyDown", { + ...keyboardEvent, + key: "Home", + }); + await fireEvent(slider, "keyDown", { + ...keyboardEvent, + key: "End", + }); + + expect(onChange.mock.calls).toStrictEqual([[0], [1], [-1]]); + expect(slider.props).toMatchObject({ + "aria-valuenow": 3, + "aria-valuetext": "Negative", + }); + expect(screen.getByTestId("scale-control", hidden)).toHaveStyle({ + flexDirection: "column-reverse", + }); + }); + + it("implements Arrow, Home, and End keys and consumes only handled keys", async () => { + const onChange = jest.fn<(value: number) => void>(); + const onChangeEnd = jest.fn>(); + const onInteractionStart = + jest.fn>(); + + await render( + + ); + + const right = await pressKey("ArrowRight"); + const up = await pressKey("ArrowUp"); + const left = await pressKey("ArrowLeft"); + const down = await pressKey("ArrowDown"); + const home = await pressKey("Home"); + const end = await pressKey("End"); + + const ignored = await pressKey("PageUp"); + expect( + [right, up, left, down, home, end].map((event) => ({ + prevented: event.preventDefault.mock.calls.length, + stopped: event.stopPropagation.mock.calls.length, + })) + ).toStrictEqual([ + { prevented: 1, stopped: 1 }, + { prevented: 1, stopped: 1 }, + { prevented: 1, stopped: 1 }, + { prevented: 1, stopped: 1 }, + { prevented: 1, stopped: 1 }, + { prevented: 1, stopped: 1 }, + ]); + expect({ + prevented: ignored.preventDefault.mock.calls.length, + stopped: ignored.stopPropagation.mock.calls.length, + }).toStrictEqual({ prevented: 0, stopped: 0 }); + expect({ + changes: onChange.mock.calls, + ends: onChangeEnd.mock.calls, + starts: onInteractionStart.mock.calls, + }).toStrictEqual({ + changes: [[2.5], [3], [2.5], [2], [1], [5]], + ends: [ + [2.5, { cancelled: false, source: "keyboard" }], + [3, { cancelled: false, source: "keyboard" }], + [2.5, { cancelled: false, source: "keyboard" }], + [2, { cancelled: false, source: "keyboard" }], + [1, { cancelled: false, source: "keyboard" }], + [5, { cancelled: false, source: "keyboard" }], + ], + starts: [ + [2, { source: "keyboard" }], + [2.5, { source: "keyboard" }], + [3, { source: "keyboard" }], + [2.5, { source: "keyboard" }], + [2, { source: "keyboard" }], + [1, { source: "keyboard" }], + ], + }); + expect(screen.getByTestId("rating").props).toMatchObject({ + "aria-valuemin": 1, + "aria-valuenow": 5, + "aria-valuetext": "5 out of 5", + }); + }); + + it("lets Home clear only when the empty sentinel is enabled", async () => { + const onChange = jest.fn<(value: number) => void>(); + + await render( + + ); + + await fireEvent(screen.getByTestId("rating"), "keyDown", { + nativeEvent: { key: "Home" }, + preventDefault: jest.fn(), + }); + + expect(onChange).toHaveBeenCalledWith(0); + expect(screen.getByTestId("rating").props).toMatchObject({ + "aria-valuemin": 0, + "aria-valuenow": 0, + "aria-valuetext": "0 out of 5", + }); + }); + + it("keeps the empty lower bound stable and preserves modified shortcuts", async () => { + const onChange = jest.fn<(value: number) => void>(); + + await render( + + ); + + const left = await pressKey("ArrowLeft"); + const down = await pressKey("ArrowDown"); + const altLeft = await pressKey("ArrowLeft", { altKey: true }); + + expect(onChange).not.toHaveBeenCalled(); + expect([ + left.preventDefault.mock.calls.length, + down.preventDefault.mock.calls.length, + altLeft.preventDefault.mock.calls.length, + ]).toStrictEqual([1, 1, 0]); + expect(screen.getByTestId("rating").props).toMatchObject({ + "aria-valuemin": 0, + "aria-valuenow": 0, + }); + }); + + it("removes disabled controls from the tab order and ignores input", async () => { + const onChange = jest.fn<(value: number) => void>(); + + await render( + + ); + const slider = screen.getByTestId("rating"); + + expect(slider.props).toMatchObject({ + accessibilityState: { disabled: true }, + "aria-disabled": true, + role: "slider", + tabIndex: -1, + }); + expect(slider.props).not.toHaveProperty("onKeyDown"); + await fireEvent(slider, "keyDown", { key: "ArrowRight" }); + expect(onChange).not.toHaveBeenCalled(); + const control = screen.getByTestId("rating-control", hidden); + expect(control.props).not.toHaveProperty("pointerEvents"); + expect(StyleSheet.flatten(control.props.style)).toMatchObject({ + pointerEvents: "none", + }); + }); + + it("composes consumer focus handlers with a visible browser focus ring", async () => { + const onBlur = jest.fn>(); + const onFocus = jest.fn>(); + + const view = await render( + + ); + const slider = screen.getByTestId("rating"); + const focusEvent = { nativeEvent: {} }; + + await fireEvent(slider, "focus", focusEvent); + expect(onFocus).toHaveBeenCalledWith(focusEvent); + expect(slider).toHaveStyle({ + boxShadow: "0 0 0 4px #FFFFFF", + outlineColor: "#1D4ED8", + outlineOffset: 2, + outlineStyle: "solid", + outlineWidth: 2, + }); + + await fireEvent(slider, "blur", focusEvent); + expect(onBlur).toHaveBeenCalledWith(focusEvent); + expect(StyleSheet.flatten(slider.props.style)).not.toHaveProperty( + "outlineWidth" + ); + + await view.rerender( + + ); + await fireEvent(slider, "focus", focusEvent); + expect(slider).toHaveStyle({ outlineColor: "#D946EF" }); + }); + + it("preserves scrolling on the cross axis for drag-enabled tracks", async () => { + const view = await render( + + ); + + expect( + StyleSheet.flatten( + screen.getByTestId("rating-control", hidden).props.style + ) + ).toMatchObject({ + touchAction: "pan-y", + userSelect: "none", + }); + + await view.rerender( + + ); + expect( + StyleSheet.flatten( + screen.getByTestId("rating-control", hidden).props.style + ) + ).toMatchObject({ + touchAction: "pan-x", + userSelect: "none", + }); + + await view.rerender( + + ); + expect( + StyleSheet.flatten( + screen.getByTestId("rating-control", hidden).props.style + ) + ).not.toHaveProperty("touchAction"); + }); + + it("keeps Web responder dragging local when locationX changes", async () => { + const onChange = jest.fn<(value: number) => void>(); + + await render( + + ); + const control = screen.getByTestId("rating-control", hidden); + + await fireEvent(control, "responderGrant", { + nativeEvent: { + locationX: 10, + locationY: 20, + pageX: 110, + pageY: 20, + touches: [{}], + }, + }); + await fireEvent(control, "responderMove", { + nativeEvent: { + locationX: 999, + locationY: 20, + pageX: 175, + pageY: 21, + touches: [{}], + }, + }); + + expect(onChange).toHaveBeenCalledWith(3); + expect(screen.getByTestId("rating").props).toMatchObject({ + "aria-valuenow": 3, + "aria-valuetext": "3 out of 5", + }); + }); + + it("combines exact static values with labels and no slider behavior", async () => { + await render( + + + + + ); + + expect(screen.getByTestId("display").props).toMatchObject({ + accessibilityLabel: "Average review, 4.37 out of 5", + accessibilityRole: "image", + "aria-disabled": true, + "aria-label": "Average review, 4.37 out of 5", + }); + expect(screen.getByTestId("scale").props).toMatchObject({ + accessibilityLabel: "Sentiment, Neutral", + accessibilityRole: "image", + "aria-disabled": true, + "aria-label": "Sentiment, Neutral", + }); + expect([ + screen.getByTestId("display").props.accessibilityValue === undefined, + screen.getByTestId("display").props.tabIndex === undefined, + screen.getByTestId("scale").props.accessibilityValue === undefined, + screen.getByTestId("scale").props.onKeyDown === undefined, + ]).toStrictEqual([true, true, true, true]); + }); + + it("uses the JavaScript animation driver for Web completion feedback", async () => { + const remove = jest.fn<() => void>(); + const start = jest.fn<() => void>(); + const originalTiming = Animated.timing; + jest + .spyOn(AccessibilityInfo, "isReduceMotionEnabled") + .mockResolvedValue(false); + jest + .spyOn(AccessibilityInfo, "addEventListener") + .mockReturnValue({ remove }); + const timing = jest + .spyOn(Animated, "timing") + .mockImplementation((value, config) => ({ + ...originalTiming(value, config), + start, + })); + const view = await render(); + + await act(async () => { + await Promise.resolve(); + }); + await tap("rating", 70); + + expect(timing).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + isInteraction: false, + useNativeDriver: false, + }) + ); + expect(start).toHaveBeenCalledTimes(1); + + await view.unmount(); + expect(remove).toHaveBeenCalledTimes(1); + }); +}); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..97635fe --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,480 @@ +# Version 4 architecture and integrated release plan + +Status date: **2026-07-28** + +## Release contract + +The next version ships as one integrated pull request and one release. There are no intermediate packages, temporary feature flags, parallel gesture engines, or compatibility components to remove later. + +The release is complete only when all of these move together: + +- numeric rating correctness; +- tap/drag/keyboard/accessibility interaction; +- exact static display; +- semantic scales; +- custom rendering; +- RTL and vertical layout; +- reduced-motion animation and list performance; +- examples, migration guidance, package compatibility, and release evidence. + +β€œLatest” means the latest validated combination, not blindly accepting every new dependency. A dependency update that prevents the main check or React Native matrix from running is not release-ready. + +## Architectural decisions + +### Compose three jobs instead of one mode-heavy component + +The public API has three focused components: + +| Component | Job | Empty value | Interaction allocation | +| --- | --- | --- | --- | +| `Rating` | Select a numeric aggregate across repeated visual items | `0` | Only when not `readOnly` | +| `RatingDisplay` | Render an exact read-only aggregate | `0` | None | +| `RatingScale` | Select an ordered semantic choice such as NPS/Likert/emoji | `null` | Only when not `readOnly` | + +This prevents incompatible semantics from accumulating as boolean props on one component. In particular, a star rating can safely reserve zero for β€œunrated,” while NPS and bipolar scales must preserve zero and negative values. + +### One root interaction surface + +The visual items do not each own a Pressable, PanResponder, listener, or accessibility stop. One measured root track maps position to a logical tick and one accessible root describes the complete control. + +This improves: + +- fractional selection across item boundaries; +- drag continuity through gaps; +- responder negotiation with a parent ScrollView; +- callback deduplication; +- keyboard and assistive-technology parity; +- allocation behavior in repeated controls. + +### Core React Native is sufficient + +The control uses the Gesture Responder System rather than adding Gesture Handler or Reanimated. A rating input needs axis-intent negotiation and simple position mapping, not a general gesture graph. Avoiding a gesture dependency also preserves the zero-runtime-dependency installation. + +The boundary is intentional: if future requirements add simultaneous gestures, velocity/decay, multi-touch, or complex cross-component coordination, revisit the decision rather than stretching the current responder. + +## Module map + +```mermaid +flowchart TD + Index["src/index.ts
public exports"] --> Rating["Rating
numeric state facade"] + Index --> Display["RatingDisplay
static facade"] + Index --> Scale["RatingScale<Value>
semantic state facade"] + Index --> Types["types.ts
public contracts"] + + Rating --> Root["InteractiveRoot
native + Web semantics"] + Rating --> Interaction["useRatingInteraction
state machine"] + Rating --> Pulse["useSelectionPulse
completion feedback"] + Rating --> Model["model.ts
ticks, normalization, layout mapping"] + Rating --> Track["rating-track.tsx
visuals and measured surface"] + + Scale --> Root + Scale --> Interaction + Scale --> Pulse + Scale --> Model + Scale --> Track + + Display --> Model + Display --> Track + + Pulse --> Motion["reduced-motion.ts
shared external store"] +``` + +The public facades own controlled/uncontrolled state. Internal modules own one kind of policy each; consumers never import them directly. + +## Numeric value model + +Floating-point values are converted to integer ticks before interaction: + +```text +step = clamp(finite step, 0.01, 1) +ticksPerItem = ceil(1 / step) +maxTick = maxItems Γ— ticksPerItem +``` + +Each tick is converted back by splitting it into full items plus a partial tick. The end of each item is always exactly the next integer item value. For a step that does not divide one evenly, such as `0.3`, the selectable sequence inside an item is `0.3`, `0.6`, `0.9`, then `1`, not `1.2`. + +Values are exposed on a bounded six-decimal lattice. Ceil boundaries first check whether the nearest integer multiple lands on that same exposed lattice, then fall back to the raw quotient. This stabilizes decimal boundaries such as `0.01`/`0.07`, arbitrary-precision inputs, and rational steps such as `1 / 7` without creating duplicate ticks or overshooting a value the component itself emitted. + +Invariants: + +1. `max` is a finite integer clamped to `1…100`. +2. Interactive `step` is finite and clamped to `0.01…1`. +3. `0` is the numeric unrated sentinel. +4. Every positive value normalizes to a selectable tick. +5. `min` rounds upward to the next selectable positive tick; it never permits a value lower than requested. +6. Controlled, uncontrolled, pointer, keyboard, accessibility, callback, and visual values all pass through the same model. +7. `NaN`, infinities, negative sizes, negative gaps, and out-of-range values are normalized before a native style is constructed. Item size and gap are defensively capped at `1024` so finite-but-extreme input cannot create an unsafe track extent. +8. `RatingDisplay` is different by design: without `step`, it clamps and draws the exact aggregate rather than snapping to an input tick. + +The native accessibility range uses integer ticks (`now` and `max`) to avoid platform precision conversion problems. The spoken text and Web ARIA value use the consumer-facing decimal. + +## Semantic scale model + +`RatingScale` accepts finite numbers or strings: + +```ts +interface RatingScaleItem { + value: Value; + label: string; + content?: ReactNode; +} +``` + +Invariants: + +- `null` alone means empty; `0`, negative values, and empty strings remain semantic values. +- Duplicate values are removed while preserving the first item; numeric `-0` and `0` are one semantic choice. +- Invalid values and blank labels are ignored and the list is capped at 100 items. +- Interaction uses a one-based internal tick, while callbacks return the original semantic value. +- `selectionMode="single"` highlights one choice; `selectionMode="cumulative"` highlights every semantic item through it. +- `reversed` maps the ordered source items onto the opposite logical ticks. It changes semantic progression without reversing coordinate math and remains independent of LTR/RTL layout. +- `itemExtent` gives each choice a primary-axis length independent of visual `size`. It normalizes to at least `size`, is capped at `1024`, and is exposed to custom renderers; the default horizontal text renderer uses it as available label width. +- Missing, `null`, or boolean `content` falls back to the required human-readable label. + +Keeping the original value in callbacks preserves TypeScript inference for literal string unions and avoids a separate index-to-value lookup in the consumer. + +## Position and layout model + +The track is measured on layout. A deterministic primary-axis extent is used until measurement is available. `itemPrimaryExtent` is `size` for numeric ratings and `itemExtent` for semantic scales: + +```text +extent = itemCount Γ— itemPrimaryExtent + (itemCount - 1) Γ— gap +``` + +At responder grant: + +```text +origin = pagePrimary - locationPrimary +``` + +During movement: + +```text +localPrimary = pagePrimary - origin +``` + +Move-time `locationX`/`locationY` is never trusted. This follows the failure documented in [react-native#15290](https://github.com/react/react-native/issues/15290) and [react-native-web#693](https://github.com/necolas/react-native-web/issues/693). + +Mapping rules: + +- positions outside the track clamp to its logical ends; +- horizontal RTL mirrors logical position across the measured extent; +- vertical logical progression runs bottom-to-top and is independent of locale direction; +- the midpoint of a visual gap decides which adjacent selectable edge wins; +- fractional fill originates left in LTR, right in RTL, and bottom vertically; +- `RatingScale.reversed` maps a semantic item to the opposite logical tick; it does not add a second coordinate reversal. + +The root style applies structural direction last so a forwarded style cannot silently desynchronize visuals from hit testing. + +## Interaction state machine + +```mermaid +stateDiagram-v2 + [*] --> idle + idle --> pressed: one pointer granted + pressed --> idle: release / commit tap + pressed --> cancelled: tap moves beyond slop + pressed --> cancelled: cross-axis intent dominates + pressed --> dragging: primary-axis intent dominates + pressed --> dragging: release proves primary-axis intent + pressed --> idle: responder terminated before acceptance + dragging --> dragging: distinct tick / emit onChange + dragging --> idle: release / emit final + completion + dragging --> cancelled: termination, disable, structure change, or multi-touch + cancelled --> idle: release or reset +``` + +Constants are implementation policy, not public API: + +- movement slop: 7 logical pixels; +- axis dominance ratio: 1.25. + +Responder behavior: + +1. A single pointer can enter `pressed`; multi-touch is rejected. +2. `pressed` is a visual preview only. It fires no lifecycle callback until a tap releases or a deliberate drag is accepted. +3. In `tap` mode, movement beyond slop cancels the pending tap. +4. In `tap-and-drag`, dominant cross-axis movement cancels and permits the parent scroll responder to take over. +5. Release coordinates are checked against the same slop and axis rules. A tap cannot commit merely because no move event arrived; a primary-dominant release in drag mode can still accept and commit the drag. +6. Before drag acceptance, termination requests return true. Once a primary-axis drag is accepted, termination requests return false so the selected value remains stable. +7. A second pointer cancels the gesture. If a drag was already accepted, cancellation ends it exactly once. +8. Disabling the control or changing value structure such as max/step/orientation/direction during an accepted drag cancels it. The final callback decodes through the grant-time model. +9. Drag movement emits each tick at most once, regardless of controlled parent latency. +10. A post-acceptance termination reports `onChangeEnd(..., { cancelled: true })`. +11. No animation runs while dragging. + +On the Web, the horizontal track sets `touchAction: "pan-y"` and a vertical track sets `touchAction: "pan-x"` when drag is enabled. That preserves the browser's cross-axis scrolling contract. + +## Controlled state and callbacks + +The prop is always the source of truth in controlled mode. During an active gesture, a local draft tick gives immediate visual response while the parent renders. The gesture also retains its last emitted tick so a slow controlled parent does not receive repeated identical changes. + +Callback contract: + +| Callback | Frequency | Intended use | +| --- | --- | --- | +| `onInteractionStart(value, { source })` | Once per accepted pointer, keyboard, or accessibility interaction | Interaction analytics and lightweight UI state | +| `onChange(value)` | Once per distinct selected tick/value; can be many during drag | Local controlled state | +| `onChangeEnd(value, { source, cancelled })` | Once per accepted interaction | Persistence, validation, analytics completion | + +Keyboard and accessibility operations still have a start/end lifecycle when already at a boundary, but emit no `onChange` if the value did not change. + +`allowClear` is deliberately narrow: + +- a true same-value tap clears; +- decrement at the minimum clears; +- Web Home preserves an existing empty sentinel; from a nonempty value it selects zero only with `allowClear`, otherwise it selects `min`; +- no nonempty interaction returns to the empty sentinel unless `allowClear` is enabled. + +An incidental drag back through the starting tick does not become a same-value-tap clear. + +## Accessibility architecture + +### Native + +- One root is `accessible` with `accessibilityRole="adjustable"`. +- Its numeric accessibility value contains integer tick `min`, `max`, and `now`, plus localized text. +- Increment/decrement actions use the same tick functions as keyboard input. +- Visual descendants are hidden from the accessibility tree, preventing five or more repetitive focus stops. +- `disabled` is reflected in accessibility state and removes actions. +- Static components use one image/content semantic with accessible value text. + +### Web + +React Native Web maps many accessibility props, but its current handling does not provide the complete numeric slider contract from the native `accessibilityValue` object. The root therefore receives direct: + +- `role="slider"`; +- `aria-label`, `aria-disabled`, `aria-orientation`; +- `aria-valuemin`, `aria-valuemax`, `aria-valuenow`, `aria-valuetext`; +- `tabIndex={0}` when enabled and `-1` when disabled; +- a two-color visible focus indicator: a blue outline plus a white separation ring. `focusStyle` composes after the default while focused. + +Keys follow the [WAI-ARIA slider pattern](https://www.w3.org/WAI/ARIA/apg/patterns/slider/): + +| Key | Result | +| ----------------------- | ------------------------------------------ | +| Right Arrow / Up Arrow | Increment one tick | +| Left Arrow / Down Arrow | Decrement one tick | +| Home | Minimum, or empty when clearing is allowed | +| End | Maximum | + +Handled unmodified keys prevent default and stop propagation. Unrelated keys and Arrow/Home/End combinations with Alt, Control, or Meta are left to the application. + +### Touch target and visual access + +The interactive cross axis is at least 44pt on iOS and 48dp elsewhere, following [Apple](https://developer.apple.com/design/human-interface-guidelines/accessibility) and [Android](https://developer.android.com/guide/topics/ui/accessibility/views/apps-views?hl=en) guidance. The primary-axis width remains the actual item width, avoiding overlapping hit targets and misleading gaps. + +The default selected star is filled (`β˜…`) while the unselected star is outlined (`β˜†`), so state is not encoded by color alone. Default colors are chosen for strong contrast on a light surface, and the default two-color focus treatment is designed to remain distinguishable on both light and dark surroundings. Applications remain responsible for checking custom colors, components, and `focusStyle` in their actual backgrounds and themes against [WCAG non-text contrast](https://www.w3.org/TR/WCAG22/#non-text-contrast) and [WCAG Focus Appearance](https://www.w3.org/WAI/WCAG22/Understanding/focus-appearance.html). + +Static visuals expose one image/content semantic by default. `RatingDisplay decorative` and a read-only `RatingScale decorative` hide a duplicate visual subtree when adjacent content already communicates the same value; `decorative` is ignored for an interactive scale. + +## Animation and reduced motion + +Animation communicates completion, not continuous value: + +- one `Animated.Value` per interactive control; +- a 150ms scale from `0.92` to `1`; +- transform-only native-driver animation on iOS and Android; +- `useNativeDriver: false` on the Web; +- `isInteraction: false`, so list rendering is not held behind an interaction handle; +- no bounce, spring, or delayed timer after every drag tick. + +Reduced motion is a module-level external store: + +- the first animated control creates one `AccessibilityInfo` subscription; +- all controls share its snapshot; +- the last subscriber removes the native listener; +- an async preference response is generation-guarded so a stale request cannot update an unmounted store; +- initial and server snapshots conservatively reduce motion; +- static and `animated={false}` controls never subscribe. + +## Rendering and allocation strategy + +- `RatingDisplay` renders only normalized visuals and one accessible root. +- Read-only `RatingScale` uses the same static principle. +- Responder callbacks are stable and read the latest configuration/callback refs. +- Numeric and semantic item components are memoized; stable untouched semantic items have a render-count regression test. +- Visual children use registered React Native styles for `pointerEvents="none"`/a box-only track so custom content cannot split responder ownership without leaking native-only pointer-event values into React Native Web inline CSS. +- Only the selected completion item receives the shared animated transform. +- Invalid custom values are normalized at the facade, not repeatedly inside each visual item. +- Consumer `style`, focus/blur handlers, test IDs, and refs compose at the root. Owned responder, keyboard, focusability, pointer-routing, role/tab-order, ARIA, and native accessibility props are excluded from the public root type and stripped again at runtime for JavaScript or broad object spreads. + +No performance claim should be based on allocation reasoning alone. Automated gates cover static allocation boundaries, memoized-item render counts, packed contents, and supported consumer builds. Release-mode list profiling remains a publication check because timing thresholds inside shared CI are not reliable device benchmarks. + +## Integrated implementation plan + +The phases are review boundaries inside one pull request, not separate releases. + +### Phase 1: evidence and public contract + +- Freeze the dated competitor/source/issue evidence. +- Define `Rating`, `RatingDisplay`, and generic `RatingScale` responsibilities. +- Define callback lifecycle, value sentinels, layout terms, and normalization bounds before visual implementation. + +Gate: every public prop has one semantic meaning and no component relies on an ambiguous zero/null convention. + +### Phase 2: pure model + +- Implement numeric tick conversion and display normalization. +- Implement track/gap/direction/orientation position mapping. +- Implement semantic scale mapping and discrete increment/decrement/Home functions. + +Gate: boundary-focused model tests run without a renderer for non-divisor steps, invalid numbers, RTL, vertical, gaps, min, and clear; facade tests cover semantic reversal and duplicate scale values. + +### Phase 3: static rendering + +- Implement common track and numeric/scale visuals. +- Implement exact `RatingDisplay`. +- Route `readOnly` facades to allocation-light display components. +- Add typed custom render slots and accessible static text. + +Gate: static trees contain no responder handlers, Animated values, or reduced-motion subscriptions and render exact values such as `4.37`. + +### Phase 4: interaction engine + +- Implement the `idle β†’ pressed β†’ dragging/cancelled` state machine. +- Use grant-time origin plus page coordinates. +- Add axis slop/dominance, termination rules, multi-touch cancellation, local draft, and per-gesture deduplication. +- Connect tap, drag, min, clear, and semantic mapping to the pure model. + +Gate: responder tests prove cross-axis yield, primary-axis retention, callback order, cancellation, outside bounds, gaps, slow controlled parents, and dynamic callback/config freshness. + +### Phase 5: accessibility, Web, and motion + +- Add the native adjustable root and direct Web slider adapter. +- Add keyboard/Home/End behavior, focus composition, and disabled semantics. +- Add target-size policy, visual subtree hiding, and localized value text. +- Add one completion pulse and the shared reduced-motion store. + +Gate: native accessibility-action tests, Web ARIA/key tests, focus tests, reduced-motion subscription tests, and a manual VoiceOver/TalkBack/keyboard pass. + +### Phase 6: consumer proof + +- Update the type-consumer fixture for every component and generic inference. +- Provide examples for controlled/uncontrolled, drag, exact FlatList display, custom rendering, RTL, vertical, NPS, Likert, emoji, and negative scales. +- Install the packed tarball into a clean Expo example, type-check it, export Web, and server-render representative React Native Web trees. +- Rewrite README/package discovery text around verified use cases. + +Gate: examples type-check, package imports resolve in ESM/CommonJS, the packed package completes an Expo Web export, and the React Native Web DOM smoke rejects render diagnostics, missing slider/decorative semantics, or invalid pointer-event output. + +### Phase 7: release evidence + +- Run formatting, lint, TypeScript, unit/coverage, build, `publint`, Are the Types Wrong, tooling/security checks, and supported React Native matrices. +- Profile a large static list and a smaller interactive list in release mode before publication; keep deterministic render-count regressions in CI. +- Review the packed tarball, exports, peer ranges, bundle/package size, and changelog. +- Link issue dispositions and this evidence in the draft pull request. + +Gate: one green integrated pull request. Versioning remains with Release Please; no manual version-only intermediate commit. + +## QA matrix + +### Pure model + +- max/min/step normalization, including `NaN`, infinities, negative values, and item cap; +- whole, half, quarter, tenth, minimum `0.01`, and non-divisor steps; +- exact display versus intentionally snapped display; +- LTR/RTL, horizontal/vertical, gap midpoints, outside bounds; +- scale null/zero/negative/string values, duplicates, invalid values, `reversed`, and selection modes. + +### Interaction + +- tap commit, same-value clear, tap movement cancellation; +- primary drag threshold and cross-axis ScrollView yield; +- grant/move coordinate conversion using changing event targets; +- distinct-tick emissions and slow controlled parent; +- responder termination before and after drag acceptance; +- multi-touch rejection/cancellation; +- dynamic callbacks and dynamic max/min/step/orientation/direction; +- uncontrolled state re-normalization when configuration changes. + +### Accessibility and Web + +- one adjustable native node; visual descendants hidden; +- native increment/decrement and boundary no-op; +- integer native range plus localized text; +- Web slider role, direct ARIA range/value/orientation, disabled tab order; +- Arrow/Home/End behavior, preventDefault, and unrelated key pass-through; +- focus/blur callback composition and visible focus style; +- static image/content semantics; +- reduced-motion initial, changed, rejected-promise, shared-subscription, and cleanup paths. + +### Rendering and performance + +- exact partial masks and fill origin for LTR/RTL/bottom-to-top vertical; +- custom render props for numeric and semantic items; +- target dimensions without primary-axis inflation; +- no interactive allocations on static paths; +- one responder root and one pulse value per interactive control; +- stable semantic-item render counts in CI plus a release-mode list profile before publication; +- no animation during drag and native-driver completion on native. + +### Package and compatibility + +- minimum and current supported React/React Native pairs; +- iOS, Android, and React Native Web; +- Expo managed Web export plus packed-package React Native Web server-render smoke; +- ESM, CommonJS, TypeScript declarations, and source export condition; +- packed contents, `sideEffects: false`, runtime dependency count, and peer ranges; +- consumer fixture generic inference for literal-string scales. + +## Release gates + +Automated baseline: + +```sh +bun run check +``` + +That command must include formatting, lint, TypeScript, coverage, package build/validation, and repository tooling checks. The release PR must also show green React Native compatibility jobs. + +Manual release checklist: + +- VoiceOver: focus, value announcement, increment/decrement, disabled, static; +- TalkBack: the same paths; +- Web: mouse, touch, keyboard, two-color focus indicator/custom `focusStyle`, browser zoom, high contrast; +- vertical rating inside horizontal scroll and horizontal rating inside vertical scroll; +- LTR application, RTL application, explicit local direction override; +- reduced motion enabled and disabled; +- FlatList with exact static values and interactive rows; +- dark/light custom backgrounds and localized value text. + +## Explicit non-goals for this release + +- Sparse disabled indexes. `min` solves the verified request without creating discontinuous slider semantics. +- Haptics or sound. These require application policy and platform permissions; callbacks let the consumer add them deliberately. +- A bundled icon/SVG library. The core star and render slots avoid forcing a visual dependency. +- Per-item accessibility stops. The control is one logical slider. +- Velocity, momentum, or spring-based drag. A rating should select deterministically where the user points. +- Multi-touch rating. +- Manual package version edits outside the existing Release Please flow. + +## Definition of done + +The next version is ready when: + +1. Every local issue/PR has a documented disposition. +2. Verified competitor pain has a corresponding invariant and regression test, not merely a marketing bullet. +3. Static and interactive responsibilities remain separable in code and package types. +4. Native, Web, RTL, vertical, controlled, uncontrolled, reduced-motion, and semantic-scale paths pass their matrices. +5. The packed package has zero runtime dependencies and valid ESM/CommonJS/type exports. +6. README examples match the shipped public API and the comparison remains source-linked and dated. +7. The integrated pull request is green and reviewable. +8. Release Please can produce the one intended next version without an intermediate publish. + +## Primary references + +- [React Native Gesture Responder System](https://reactnative.dev/docs/gesture-responder-system) +- [React Native PanResponder](https://reactnative.dev/docs/panresponder) +- [React Native Animated](https://reactnative.dev/docs/animated) +- [React Native performance](https://reactnative.dev/docs/performance) +- [React Native accessibility](https://reactnative.dev/docs/accessibility) +- [React Native AccessibilityInfo](https://reactnative.dev/docs/accessibilityinfo) +- [React Native I18nManager](https://reactnative.dev/docs/i18nmanager) +- [React Native Web interactions](https://necolas.github.io/react-native-web/docs/interactions/) +- [React Native Web accessibility](https://necolas.github.io/react-native-web/docs/accessibility/) +- [WAI-ARIA slider pattern](https://www.w3.org/WAI/ARIA/apg/patterns/slider/) +- [Apple accessibility](https://developer.apple.com/design/human-interface-guidelines/accessibility) +- [Android accessible views](https://developer.android.com/guide/topics/ui/accessibility/views/apps-views?hl=en) +- [WCAG 2.2 non-text contrast](https://www.w3.org/TR/WCAG22/#non-text-contrast) +- [WCAG 2.2 Focus Appearance](https://www.w3.org/WAI/WCAG22/Understanding/focus-appearance.html) +- [Dated competitor and issue evidence](./COMPETITIVE_ANALYSIS.md) diff --git a/docs/COMPETITIVE_ANALYSIS.md b/docs/COMPETITIVE_ANALYSIS.md new file mode 100644 index 0000000..4c6dae4 --- /dev/null +++ b/docs/COMPETITIVE_ANALYSIS.md @@ -0,0 +1,280 @@ +# Competitive analysis: React Native rating components + +Evidence date: **2026-07-28** + +Search capture: **2026-07-28 04:07 UTC**, using an English-language/global research surface. + +This document records the evidence behind the next version of `react-native-rating`. It separates observed facts from product inferences so the public positioning stays defensible. + +## Scope and method + +The audit combined four evidence sets: + +1. The first-two-page result depth for the query families β€œreact native rating component,” β€œreact native star rating component,” β€œReact Native ratings library,” and β€œReact Native rating input Expo Web accessibility.” +2. Current npm package metadata and the npm downloads API. +3. The published source or current release commit of recurring results. +4. Open and historically relevant GitHub issues, plus this repository's open issues and pull requests. + +The research surface merged some query results and did not expose stable per-query rank or locale metadata. Search order is also volatile and personalized. The package map is therefore a recurring-hit sample across the reviewed first-two-page depth, npm, and GitHubβ€”not a reproducible ranking or a claim that every user will see the same ordering. In-app store-review launchers such as `react-native-rate` were excluded because they solve a different job. + +The download count is the npm `last-week` result for **2026-07-18 through 2026-07-24**. `@rneui/base` is a toolkit-wide count and cannot be attributed to its Rating component alone. + +## Search-hit and package map + +| Package | Audited release/source | npm downloads | What it establishes | +| --- | --- | --: | --- | +| [`react-native-ratings`](https://www.npmjs.com/package/react-native-ratings) | [8.1.0 / `0a8f16b`](https://github.com/Monte9/react-native-ratings/tree/0a8f16b4380626556b636e8883b06c0db9e885ec) | 135,113 | Strong name recognition; tap/swipe modes, built-in image types, review labels | +| [`@rneui/base`](https://www.npmjs.com/package/@rneui/base) | [5.0.0 / `32ae0ed`](https://github.com/react-native-elements/react-native-elements/tree/32ae0eda7c559f55a9d858fc6880ed46f7714c5b) | 68,276 toolkit-wide | Rating bundled into a broad UI system; current wrapper still derives from a swipe-rating design | +| [`react-native-star-rating-widget`](https://www.npmjs.com/package/react-native-star-rating-widget) | [1.11.0 / `c8906b6`](https://github.com/bviebahn/react-native-star-rating-widget/tree/c8906b677061ca7ac252c191419495e8cdefa381) | 16,768 | Modern TypeScript, animation, full/half/quarter input, custom icon, separate display | +| [`react-native-star-rating`](https://www.npmjs.com/package/react-native-star-rating) | [1.1.0](https://github.com/djchie/react-native-star-rating) | 3,690 | An old result that continues to rank; vector-icon-era installation and per-star buttons | +| [`@rn-vui/ratings`](https://www.npmjs.com/package/@rn-vui/ratings) | [0.5.0 / `05783ad`](https://github.com/deepktp/react-native-vikalp-ratings/tree/05783ad7fa318fbe995082fd71f69c4c572fbc25) | 2,840 | A maintained fork of the familiar swipe/tap API with some newer semantics and tests | +| [`@kolking/react-native-rating`](https://www.npmjs.com/package/@kolking/react-native-rating) | [1.4.1 / `1938275`](https://github.com/kolking/react-native-rating/tree/19382752e7a47cd5370d0e5206fc107ad52be3e2) | 2,135 | Zero dependencies, polished presets, exact decimal display, custom images | +| [`react-native-stars`](https://www.npmjs.com/package/react-native-stars) | [1.2.2 / `8a967ab`](https://github.com/Extrct/react-native-stars/tree/8a967ab17462dd1de3af445dd220da6f9197b2a0) | 1,785 | Simple full/half selection, arbitrary partial display, images or elements | +| [`@aashu-dubey/react-native-rating-bar`](https://www.npmjs.com/package/@aashu-dubey/react-native-rating-bar) | [0.3.6 / `34fb0cf`](https://github.com/Aashu-Dubey/react-native-rating-bar/tree/34fb0cf0c0b38cdf3f8c002d42ef6fc435978175) | 152 | Gesture Handler implementation with horizontal/vertical/reverse and RTL options | +| [`react-native-rating-star`](https://www.npmjs.com/package/react-native-rating-star) | [0.2.1](https://github.com/liuchungui/react-native-star-rating) | 2 | A 2016 image-based result; useful as search-history context, not a modern baseline | + +Primary download endpoint pattern: `https://api.npmjs.org/downloads/point/last-week/`. + +## This repository's open work + +### Issues + +| Item | Verified request | Next-version disposition | +| --- | --- | --- | +| [#4: half-filled stars](https://github.com/f0rr0/react-native-rating/issues/4) | Display and select values such as 2.5 and 4.5 | Close with one numeric tick model. Half, quarter, tenth, and other supported steps use the same selection and fill calculation. | +| [#8: pass an element instead of images](https://github.com/f0rr0/react-native-rating/issues/8) | Use vector icons/components and control their colors | Close with `renderItem`, including `fill`, direction, orientation, state, size, and colors. | +| [#13: disabled indexes or a minimum](https://github.com/f0rr0/react-native-rating/issues/13) | The body explicitly accepts either sparse disabled stars or `min={3}` | Close with `min`. Sparse holes would make slider increment/decrement and drag semantics harder to predict; the verified minimum use case has a coherent model. | +| [#14: ratings in FlatList](https://github.com/f0rr0/react-native-rating/issues/14) | Identify and render independent list-row ratings | Close with controlled row values plus the dedicated `RatingDisplay` static path for review/aggregate lists. | +| [#10: Greenkeeper activation](https://github.com/f0rr0/react-native-rating/issues/10) | Obsolete Greenkeeper service setup | Close as obsolete; current dependency automation and Release Please replace it. | + +### Pull requests + +| Item | State on 2026-07-28 | Decision | +| --- | --- | --- | +| [#9: update rating externally](https://github.com/f0rr0/react-native-rating/pull/9) | Open, conflicting, edits the removed `src/rating.js` architecture | Superseded by the controlled `value` API introduced in v3 and retained here. Do not transplant the old diff. | +| [#11: selected/unselected elements](https://github.com/f0rr0/react-native-rating/pull/11) | Open, conflicting, edits the removed `src/rating.js` architecture | Superseded by the typed `renderItem` API, which also handles fractional fill and interaction state. | +| [#18: Jest dependency group](https://github.com/f0rr0/react-native-rating/pull/18) | Open; dependency review passes, but the main Check fails and the React Native matrix is skipped. The Jest 30 runtime calls `clearMocksOnScope`, which is absent from React Native preset's Jest 29 module mocker. | Structurally mergeable on GitHub, but not mergeable as a validated change today. Do not fold it in merely because it is newer. Reproduce a Jest 30 upgrade only when Jest and the React Native preset/module-mocker generation agree and the full matrices pass. | + +The exact `clearMocksOnScope` failure is preserved in [#18's Check job](https://github.com/f0rr0/react-native-rating/actions/runs/30319643698/job/90152631361). + +The product changes above belong in one next-version pull request. There is no intermediate compatibility component or partial feature release. + +## Competitor implementation and issue audit + +### `react-native-star-rating-widget` + +What works well: + +- A clear split between interactive [`StarRating`](https://github.com/bviebahn/react-native-star-rating-widget/blob/c8906b677061ca7ac252c191419495e8cdefa381/src/StarRating.tsx) and static [`StarRatingDisplay`](https://github.com/bviebahn/react-native-star-rating-widget/blob/c8906b677061ca7ac252c191419495e8cdefa381/src/StarRatingDisplay.tsx). +- Explicit interaction start/end callbacks, a custom icon component, native adjustable actions, animation, and automatic RTL handling. + +Verified limitations and pain: + +- The responder returns true for start, start capture, move, and move capture. That is observable in [`StarRating.tsx`](https://github.com/bviebahn/react-native-star-rating-widget/blob/c8906b677061ca7ac252c191419495e8cdefa381/src/StarRating.tsx#L200-L270) and can compete aggressively with parent scrolling. +- Move calculation reads `nativeEvent.locationX`. React Native documented the moving-target failure in [react-native#15290](https://github.com/react/react-native/issues/15290), and React Native Web documented the same class of problem in [react-native-web#693](https://github.com/necolas/react-native-web/issues/693). +- Selection is limited to the fixed `full`, `half`, and `quarter` modes. +- Every item owns an animation value; the interactive component animates during interaction rather than limiting motion to completion. +- [#78](https://github.com/bviebahn/react-native-star-rating-widget/issues/78) remains open for its Web accessibility prop warning. RTL demand was visible in [#70](https://github.com/bviebahn/react-native-star-rating-widget/issues/70) before automatic support landed. +- Source review suggests repeated controlled drag events can recur while a slow parent still supplies the old `rating`, because deduplication compares against that prop rather than a gesture-local last-emitted tick. This is an inference from source, not a reported issue. + +### `@kolking/react-native-rating` + +What works well: + +- Zero runtime dependencies, polished light/dark variants, exact decimal controlled display, and a compact API. +- It correctly records `pageX - locationX` at grant and uses page coordinates during movement, a sound response to the known `locationX` problem. +- Its README explicitly warns list users about pending callbacks, making the performance tradeoff discoverable. + +Verified limitations and pain: + +- The root responder always accepts start and returns false from termination requests in [`Rating.tsx`](https://github.com/kolking/react-native-rating/blob/19382752e7a47cd5370d0e5206fc107ad52be3e2/src/Rating.tsx#L64-L137). There is no axis-intent phase for a surrounding ScrollView. +- User input resolves with `Math.ceil` to whole items even though controlled visuals can show decimal fill. +- Disabled/list use still constructs root animated values, and every symbol constructs an animation value in [`RatingSymbol.tsx`](https://github.com/kolking/react-native-rating/blob/19382752e7a47cd5370d0e5206fc107ad52be3e2/src/RatingSymbol.tsx). +- The audited source has no adjustable accessibility node, Web slider semantics, or keyboard handler. +- Reported pain includes coordinate handling [#14](https://github.com/kolking/react-native-rating/issues/14) and [#19](https://github.com/kolking/react-native-rating/issues/19), large-list performance [#15](https://github.com/kolking/react-native-rating/issues/15), and reverse-order demand [#35](https://github.com/kolking/react-native-rating/issues/35). These issues are closed, but they validate the categories. + +### `react-native-ratings` + +What works well: + +- A mature, recognizable API with separate tap and swipe experiences, built-in star/heart/rocket/bell images, custom images, start/swipe/finish callbacks, fractions, minimum values, and Airbnb-style review labels. +- Its long adoption history proves demand for both simple star input and descriptive feedback choices. + +Verified limitations and pain: + +- The audited 8.1.0 [`SwipeRating`](https://github.com/Monte9/react-native-ratings/blob/0a8f16b4380626556b636e8883b06c0db9e885ec/src/SwipeRating.tsx) creates a PanResponder and new animated objects through its interaction path, measures against window coordinates, and keeps read-only behavior inside the same component. +- Fractional callback and visual behavior diverges for `jumpValue` in open [#155](https://github.com/Monte9/react-native-ratings/issues/155). +- Invalid layout math can reach a native `NaN` width crash: [#183](https://github.com/Monte9/react-native-ratings/issues/183). +- Custom/background image rendering has open reports in [#167](https://github.com/Monte9/react-native-ratings/issues/167), [#175](https://github.com/Monte9/react-native-ratings/issues/175), and [#197](https://github.com/Monte9/react-native-ratings/issues/197). +- Accessibility is still requested in [#172](https://github.com/Monte9/react-native-ratings/issues/172), RTL is still open in [#81](https://github.com/Monte9/react-native-ratings/issues/81), and a negative-to-positive/bidirectional scale is requested in [#189](https://github.com/Monte9/react-native-ratings/issues/189). + +These are the strongest issue-backed opportunities because they span correctness, crashes, rendering reliability, accessibility, international layout, and a new semantic jobβ€”not cosmetic preference alone. + +### React Native Elements Rating + +What works well: + +- It is discoverable inside a broad design system with current releases and a familiar API. +- The current [`Rating`](https://github.com/react-native-elements/react-native-elements/blob/32ae0eda7c559f55a9d858fc6880ed46f7714c5b/packages/base/src/Rating/Rating.tsx) is small and delegates to the toolkit's swipe-rating implementation. + +Verified limitations: + +- Installing a UI toolkit is a different dependency decision from adopting a focused core-only control. +- The delegated [`SwipeRating`](https://github.com/react-native-elements/react-native-elements/blob/32ae0eda7c559f55a9d858fc6880ed46f7714c5b/packages/base/src/AirbnbRating/SwipeRating.tsx) retains window-relative measurement and immediate PanResponder acceptance. +- It does not provide a generic semantic scale or a dedicated exact aggregate display component. + +### `@rn-vui/ratings` + +What works well: + +- It modernizes the familiar tap/swipe API with hooks, TypeScript, current tests, and some accessibility state/value coverage. + +Verified limitations: + +- Its [`SwipeRating`](https://github.com/deepktp/react-native-vikalp-ratings/blob/05783ad7fa318fbe995082fd71f69c4c572fbc25/src/SwipeRating.tsx) adds an Animated listener during render, accepts the responder immediately, and intentionally suppresses hook dependencies around interaction callbacks. Dynamic configuration/callback freshness is therefore risky. +- It retains image masking, window-relative measurement, and the older fractions/jump model. +- Static/read-only content remains in the same swipe implementation rather than a minimal display path. + +### `react-native-stars` + +What works well: + +- A small API, arbitrary partial display, half-star selection, and support for images or React elements. + +Verified limitations: + +- The [1.2.2 source](https://github.com/Extrct/react-native-stars/blob/8a967ab17462dd1de3af445dd220da6f9197b2a0/index.js) uses class lifecycle code, loose equality, and `value || fallback` selection. A legitimate controlled zero can therefore be replaced by a legacy prop. +- Half selection creates two touchables per star; full selection creates one per star. There is no single slider semantic, drag input, keyboard path, RTL model, or TypeScript contract. +- Its last source release predates the current React 19/React Native architecture. + +### `@aashu-dubey/react-native-rating-bar` + +What works well: + +- The widest layout feature set in the focused competitors: Gesture Handler, tap and pan, half values, minimum/maximum, horizontal, vertical, vertical-reverse, explicit layout direction, and custom elements. + +Verified limitations: + +- It requires `react-native-gesture-handler`, which is a reasonable choice for complex gesture systems but a meaningful cost for a small form control. +- The audited [`RatingBar.tsx`](https://github.com/Aashu-Dubey/react-native-rating-bar/blob/34fb0cf0c0b38cdf3f8c002d42ef6fc435978175/src/RatingBar.tsx) contains parallel RTL/platform coordinate branches, cloned-element state, and a source TODO noting double-render slowdown during drag callbacks. +- It supports only whole/half selection, and the audited source has no complete adjustable/ARIA keyboard model. + +### Older first-page/second-page results + +`react-native-star-rating` and `react-native-rating-star` still capture generic search traffic despite 2018 and 2016 latest releases. Their presence is an SEO lesson: an exact package name, a plain-language title, installation snippet, and stable repository history can rank for years. Their image/vector-icon installation, per-star press targets, and older React APIs are not an implementation baseline for the next version. + +## What to borrow + +These are product lessons, not copied source: + +| Lesson | Evidence | Adaptation here | +| --- | --- | --- | +| Separate interactive and static jobs | `react-native-star-rating-widget` has `StarRatingDisplay`; FlatList demand appears in local #14 | First-class `RatingDisplay`, exact by default and free of interaction allocations | +| Make interaction lifecycle explicit | Widget and Monte packages expose start/move/end phases | `onInteractionStart`, distinct `onChange`, and one `onChangeEnd` with source/cancellation | +| Custom visuals are table stakes | Widget icon component, Kolking symbols, Monte custom image, local #8 | Typed render slots that also expose fractional fill, direction, and pressed state | +| RTL and vertical layout must be intentional | Widget/Kolking RTL work, Aashu direction matrix, multiple RTL issues | `direction`, `orientation`, and semantic `reversed` are separate concepts | +| Descriptive feedback is more valuable than stars alone | Airbnb review labels and Monte #189 | Generic `RatingScale` for NPS, Likert, emoji, zero, negatives, and strings | +| A focused component should be easy to adopt | Kolking's zero-dependency pitch | Core React Native implementation with no required icon, SVG, or gesture package | +| Arbitrary aggregate display matters | Kolking and `react-native-stars` render decimals | Exact `RatingDisplay` fill, while interactive snapping remains explicit | + +## What this package can offer that the audited hits cannot + +The defensible differentiation is the combination, not a longer prop list: + +1. **Scroll-aware drag intent.** One root state machine waits for slop and primary-axis dominance, permits responder termination before lock, and retains only a deliberate rating drag. +2. **One value model end to end.** Integer ticks drive pointer selection, keyboard actions, native accessibility values, callback deduplication, and fractional visual fill. This directly targets the verified visual/callback divergence class. +3. **Accessibility parity, not a label.** One native adjustable control, direct Web ARIA slider attributes, standard keyboard keys, localized value text, visible focus, non-color-only star shapes, and reduced-motion behavior. +4. **A real static architecture.** `RatingDisplay` and read-only scales do not merely disable an interactive implementation. That directly serves FlatList/SectionList use. +5. **Semantic ratings.** `RatingScale` does not overload zero as β€œempty,” so NPS zero and negative-to-positive scales work without sentinel collisions. +6. **Independent layout semantics.** Locale direction, interaction orientation, bottom-to-top vertical progression, fill origin, and semantic reversal are explicit and testable. `reversed` remaps semantic items rather than adding conflicting coordinate branches. +7. **Core-only customization.** The default works without setup, while render slots accept the user's existing icon/SVG/design system instead of choosing one for them. +8. **Defensive boundaries.** Invalid values, counts, sizes, steps, duplicate scale items, gaps, and measured extents normalize before reaching native styles, targeting the verified `NaN` crash class. +9. **Semantic content that can fit.** `itemExtent` gives horizontal labels more primary-axis room without inflating visual size or the cross-axis target, while the same value is available to custom renderers. +10. **Intentional duplicate semantics.** Static aggregate and read-only scale visuals can be marked `decorative` when adjacent text already communicates their value; interactive controls remain one focusable slider. + +## Positioning and SEO plan + +### Search promise + +Lead with one sentence everywhere: + +> Accessible React Native star rating, drag rating, and semantic feedback scale for iOS, Android, Expo, and React Native Web. + +The supporting proof line is: fractional, RTL, Web keyboard-ready, and zero runtime dependencies. + +It contains the high-intent terms without keyword stuffing: + +- React Native rating component +- React Native star rating +- React Native drag/swipe rating +- Expo rating component +- React Native Web rating +- accessible/keyboard rating +- fractional/half-star rating +- RTL/vertical rating +- NPS, Likert, and emoji rating scale +- FlatList rating display + +### Proof before superlatives + +Do not call the package β€œthe best” as an unsupported claim. Show the difference: + +- a short clip of a horizontal rating inside a vertical ScrollView; +- keyboard + two-color focus-indicator interaction in the Web example; +- VoiceOver/TalkBack adjustable actions; +- exact `4.37` display in a large FlatList; +- LTR, RTL, vertical, and reversed semantic scale in one layout; +- NPS `0` and a `-2…2` scale proving the null-sentinel design; +- reduced-motion on/off behavior. + +The README comparison must stay versioned and sourced. Re-audit it for every major release rather than letting competitor claims go stale. + +### Conversion path + +1. The npm/GitHub fold answers compatibility, install, and a controlled five-star example. +2. The next screen proves the unique jobs: drag, `RatingDisplay`, and `RatingScale`. +3. Copy-paste examples cover the exact search intents above. +4. Callback documentation tells teams where to put state versus persistence. +5. The architecture document provides trust for maintainers evaluating gesture correctness, performance, and accessibility. + +### Release launch + +- Publish one integrated next version and one changelog story; do not fragment the message across interim releases. +- Create a concise demonstration asset and deploy a live Expo/Web example before adding a public link. The repository currently provides the local Expo example plus packed-package Web export and server-render smoke coverage; it does not yet claim a deployed demo URL. +- Announce the issue-backed outcomes: half/fractional fill, custom components, minimum ratings, FlatList display, RTL, accessibility, and semantic scales. +- Link the resolved local issues and thank the original reporters. +- Share factual implementation notes with React Native/Expo communities: coordinate math, ScrollView coexistence, Web slider semantics, and the static-path performance design are useful even to non-users. + +### Measures + +Capture a release-day baseline and review at 7, 30, and 90 days: + +- npm weekly downloads and dependent count; +- GitHub clone/traffic, stars, and README-to-install conversion where available; +- search position for the query families in the audit; +- example visits and completion of the live rating demo once a public deployment exists; +- issue mix: defects versus setup questions versus feature requests; +- bundle/package size and list-render benchmark regressions. + +Growth is not evidence of correctness. Accessibility checks, package compatibility, issue recurrence, and crash-free usage remain release guardrails. + +## Primary implementation guidance + +The product decisions were checked against current primary guidance: + +- [React Native Gesture Responder System](https://reactnative.dev/docs/gesture-responder-system) +- [React Native PanResponder](https://reactnative.dev/docs/panresponder) +- [React Native Animated](https://reactnative.dev/docs/animated) +- [React Native performance overview](https://reactnative.dev/docs/performance) +- [React Native accessibility](https://reactnative.dev/docs/accessibility) +- [React Native AccessibilityInfo](https://reactnative.dev/docs/accessibilityinfo) +- [React Native I18nManager](https://reactnative.dev/docs/i18nmanager) +- [React Native Web interactions](https://necolas.github.io/react-native-web/docs/interactions/) +- [React Native Web accessibility](https://necolas.github.io/react-native-web/docs/accessibility/) +- [WAI-ARIA Authoring Practices slider pattern](https://www.w3.org/WAI/ARIA/apg/patterns/slider/) +- [Apple accessibility guidance](https://developer.apple.com/design/human-interface-guidelines/accessibility) +- [Android accessible view guidance](https://developer.android.com/guide/topics/ui/accessibility/views/apps-views?hl=en) +- [WCAG 2.2 non-text contrast](https://www.w3.org/TR/WCAG22/#non-text-contrast) +- [WCAG 2.2 Focus Appearance](https://www.w3.org/WAI/WCAG22/Understanding/focus-appearance.html) + +The corresponding implementation invariants and release gates live in [ARCHITECTURE.md](./ARCHITECTURE.md). diff --git a/example/app.json b/example/app.json new file mode 100644 index 0000000..80f5788 --- /dev/null +++ b/example/app.json @@ -0,0 +1,14 @@ +{ + "expo": { + "name": "React Native Rating", + "slug": "react-native-rating-example", + "version": "1.0.0", + "orientation": "portrait", + "userInterfaceStyle": "light", + "newArchEnabled": true, + "web": { + "bundler": "metro", + "output": "single" + } + } +} diff --git a/example/app.tsx b/example/app.tsx new file mode 100644 index 0000000..62b16d3 --- /dev/null +++ b/example/app.tsx @@ -0,0 +1,365 @@ +import { useCallback, useState } from "react"; +import { Platform, ScrollView, StyleSheet, Text, View } from "react-native"; +import { Rating, RatingDisplay, RatingScale } from "react-native-rating"; +import type { + RatingRenderItemProps, + RatingScaleItem, +} from "react-native-rating"; + +const EXPERIENCE_ITEMS = [ + { + content: "πŸ˜•", + label: "Needs work", + value: -1, + }, + { + content: "😐", + label: "It was okay", + value: 0, + }, + { + content: "😊", + label: "Loved it", + value: 1, + }, +] as const satisfies readonly RatingScaleItem[]; + +const styles = StyleSheet.create({ + app: { + backgroundColor: "#FAF7F2", + flex: 1, + }, + card: { + backgroundColor: "#FFFFFF", + borderColor: "#E7E0D8", + borderRadius: 20, + borderWidth: 1, + gap: 14, + padding: 20, + ...Platform.select({ + web: { + boxShadow: "0 12px 32px rgba(64, 45, 28, 0.08)", + }, + }), + }, + content: { + alignSelf: "center", + gap: 20, + maxWidth: 720, + paddingBottom: 64, + paddingHorizontal: 20, + paddingTop: 48, + width: "100%", + }, + eyebrow: { + color: "#9A3412", + fontSize: 13, + fontWeight: "700", + letterSpacing: 1.2, + textTransform: "uppercase", + }, + header: { + gap: 10, + marginBottom: 4, + }, + hint: { + color: "#6B625B", + fontSize: 14, + lineHeight: 20, + }, + score: { + color: "#292524", + fontSize: 18, + fontVariant: ["tabular-nums"], + fontWeight: "700", + }, + staticRow: { + alignItems: "center", + flexDirection: "row", + flexWrap: "wrap", + gap: 12, + }, + status: { + backgroundColor: "#FFF7ED", + borderRadius: 12, + color: "#7C2D12", + fontSize: 14, + lineHeight: 20, + paddingHorizontal: 14, + paddingVertical: 10, + }, + subtitle: { + color: "#57534E", + fontSize: 17, + lineHeight: 26, + maxWidth: 600, + }, + title: { + color: "#1C1917", + fontSize: 38, + fontWeight: "800", + letterSpacing: -1.2, + lineHeight: 43, + }, + sectionTitle: { + color: "#292524", + fontSize: 18, + fontWeight: "700", + }, + showcaseCell: { + alignItems: "center", + flexGrow: 1, + gap: 10, + minWidth: 220, + }, + showcaseLabel: { + color: "#57534E", + fontSize: 13, + fontWeight: "600", + }, + showcaseRow: { + alignItems: "center", + flexDirection: "row", + flexWrap: "wrap", + gap: 24, + justifyContent: "space-around", + }, + customGlyph: { + includeFontPadding: false, + textAlign: "center", + textAlignVertical: "center", + }, + customGlyphActive: { + position: "absolute", + }, + customIcon: { + overflow: "hidden", + }, + customMask: { + overflow: "hidden", + position: "absolute", + }, + customStart: { + left: 0, + }, + customEnd: { + right: 0, + }, + customBottom: { + bottom: 0, + }, + customTop: { + top: 0, + }, +}); + +const formatScore = (value: number): string => + `${value.toFixed(2)} out of 5 stars`; + +const renderHeart = ({ + activeColor, + fill, + fillOrigin, + inactiveColor, + size, +}: RatingRenderItemProps) => { + const textStyle = [ + styles.customGlyph, + { + color: inactiveColor, + fontSize: size * 0.88, + height: size, + lineHeight: size, + width: size, + }, + ]; + const fromEnd = fillOrigin === "right"; + const fromBottom = fillOrigin === "bottom"; + const verticalOrigin = fromBottom ? styles.customBottom : styles.customTop; + + return ( + + + β™‘ + + + + β™₯ + + + + ); +}; + +export function App() { + const [score, setScore] = useState(3.75); + const [heartScore, setHeartScore] = useState(3.5); + const [intensity, setIntensity] = useState(3); + const [experience, setExperience] = useState(0); + const [status, setStatus] = useState( + "Try touch, mouse, keyboard, or a screen reader." + ); + + const handleScoreEnd = useCallback((value: number): void => { + setStatus(`Saved a ${value}-star rating.`); + }, []); + const handleExperienceEnd = useCallback((value: number | null): void => { + const label = + EXPERIENCE_ITEMS.find((item) => item.value === value)?.label ?? + "No selection"; + setStatus(`Saved β€œ${label}”.`); + }, []); + + return ( + + + React Native Rating + + One input, every way people rate. + + + Precise stars, quiet motion, semantic scales, and the same accessible + interaction model on native and Web. + + + + + + Your rating + + + {score.toFixed(2)} / 5 + + Drag across the stars. On Web, focus the control and use the arrow, + Home, or End keys. + + + + + + RTL, vertical, and your own icon + + + + RTL custom hearts + + {heartScore.toFixed(1)} + + + Vertical intensity + + + + + Direction, orientation, and fill origin stay explicit. The custom + renderer receives the same fractional value used by input and + accessibility. + + + + + + How was the experience? + + + + Semantic values can include zero and negatives without treating either + as an empty rating. + + + + + + Read-only average + + + + 4.37 + + + RatingDisplay preserves exact aggregate values and avoids interaction + work, making it the list-friendly path. + + + + + {status} + + + ); +} diff --git a/example/index.ts b/example/index.ts new file mode 100644 index 0000000..9bc6740 --- /dev/null +++ b/example/index.ts @@ -0,0 +1,5 @@ +import { registerRootComponent } from "expo"; + +import { App } from "./app"; + +registerRootComponent(App); diff --git a/example/package.json b/example/package.json new file mode 100644 index 0000000..bd7e329 --- /dev/null +++ b/example/package.json @@ -0,0 +1,28 @@ +{ + "name": "react-native-rating-example", + "version": "1.0.0", + "private": true, + "main": "index.ts", + "scripts": { + "android": "expo start --android", + "export:web": "expo export --platform web --output-dir dist", + "ios": "expo start --ios", + "start": "expo start", + "typecheck": "tsc --noEmit", + "web": "expo start --web" + }, + "dependencies": { + "@expo/metro-runtime": "57.0.7", + "expo": "57.0.8", + "react": "19.2.8", + "react-dom": "19.2.8", + "react-native": "0.86.2", + "react-native-rating": "file:..", + "react-native-web": "0.21.2" + }, + "devDependencies": { + "@types/react": "19.2.17", + "typescript": "7.0.2" + }, + "packageManager": "bun@1.3.14" +} diff --git a/example/tsconfig.json b/example/tsconfig.json new file mode 100644 index 0000000..70412f3 --- /dev/null +++ b/example/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, + "strict": true + }, + "include": ["app.tsx", "index.ts"] +} diff --git a/hk.pkl b/hk.pkl index e2d8df5..ab6fa44 100644 --- a/hk.pkl +++ b/hk.pkl @@ -11,7 +11,7 @@ local linters = new Mapping { ["tsc"] = (Builtins.tsc) { depends = List("oxfmt") - exclude = List(".github/fixtures/**") + exclude = List(".github/fixtures/**", "example/**") } ["mise"] = Builtins.mise diff --git a/oxlint.config.ts b/oxlint.config.ts index 8197d8b..13d892e 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -5,7 +5,12 @@ import react from "ultracite/oxlint/react"; export default defineConfig({ extends: [core, react, jest], - ignorePatterns: [...(core.ignorePatterns ?? []), "coverage/**", "lib/**"], + ignorePatterns: [ + ...(core.ignorePatterns ?? []), + "coverage/**", + "example/**", + "lib/**", + ], options: { typeAware: true, }, diff --git a/package.json b/package.json index cba6c3f..cb2af74 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,28 @@ { "name": "react-native-rating", "version": "3.0.0", - "description": "A small, accessible rating input for modern React Native.", + "description": "Accessible React Native star rating, drag rating, and semantic feedback scale for iOS, Android, Expo, and React Native Web.", "keywords": [ "accessibility", "android", "component", + "emoji-rating", + "expo", + "fractional-rating", + "gesture", + "half-stars", "ios", + "likert", + "nps", "rating", + "rating-input", "react-native", - "stars" + "react-native-web", + "rtl", + "slider", + "star-rating", + "stars", + "typescript" ], "homepage": "https://github.com/f0rr0/react-native-rating#readme", "bugs": { @@ -24,6 +37,7 @@ "files": [ "lib", "src", + "docs", "CHANGELOG.md", "LICENSE.md", "README.md" @@ -94,7 +108,9 @@ }, "jest": { "collectCoverageFrom": [ - "src/rating.tsx" + "src/**/*.{ts,tsx}", + "!src/index.ts", + "!src/types.ts" ], "coverageDirectory": "coverage", "coverageReporters": [ diff --git a/src/index.ts b/src/index.ts index af64d77..40dbce5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,25 @@ export { Rating } from "./rating"; +export { RatingDisplay } from "./rating-display"; +export { RatingScale } from "./rating-scale"; export type { + RatingDirection, + RatingDisplayProps, + RatingFillOrigin, + RatingInteractionDetails, + RatingInteractionEndDetails, + RatingInteractionMode, + RatingInteractionSource, + RatingOrientation, RatingProps, RatingRenderItem, RatingRenderItemProps, -} from "./rating"; + RatingRootProps, + RatingScaleItem, + RatingScaleProps, + RatingScaleRenderItem, + RatingScaleRenderItemProps, + RatingScaleSelectionMode, + RatingScaleValue, + RatingVisualProps, + ResolvedRatingDirection, +} from "./types"; diff --git a/src/internal/interactive-root.tsx b/src/internal/interactive-root.tsx new file mode 100644 index 0000000..b107929 --- /dev/null +++ b/src/internal/interactive-root.tsx @@ -0,0 +1,201 @@ +import { useCallback, useState } from "react"; +import type { ComponentRef, ReactNode, Ref } from "react"; +import { Platform, StyleSheet, View } from "react-native"; +import type { + AccessibilityActionEvent, + FocusEvent, + StyleProp, + ViewProps, + ViewStyle, +} from "react-native"; + +import type { + RatingOrientation, + RatingRootProps, + ResolvedRatingDirection, +} from "../types"; +import { getForwardedRatingRootProps } from "./root-props"; + +const ACCESSIBILITY_ACTIONS = [ + { name: "increment" as const }, + { name: "decrement" as const }, +]; + +const styles = StyleSheet.create({ + root: { + alignItems: "center", + }, + webFocus: { + borderRadius: 2, + boxShadow: "0 0 0 4px #FFFFFF", + outlineColor: "#1D4ED8", + outlineOffset: 2, + outlineStyle: "solid", + outlineWidth: 2, + }, +}); + +interface RatingKeyboardEvent { + altKey?: boolean | undefined; + ctrlKey?: boolean | undefined; + key?: string | undefined; + metaKey?: boolean | undefined; + nativeEvent?: + | { + altKey?: boolean | undefined; + ctrlKey?: boolean | undefined; + key?: string | undefined; + metaKey?: boolean | undefined; + } + | undefined; + preventDefault?: (() => void) | undefined; + stopPropagation?: (() => void) | undefined; +} + +interface WebSliderProps { + "aria-orientation": RatingOrientation; + "aria-valuemax": number; + "aria-valuemin": number; + "aria-valuenow": number; + "aria-valuetext": string; + onKeyDown?: (event: RatingKeyboardEvent) => void; + role: "slider"; + tabIndex: -1 | 0; +} + +interface InteractiveRootProps { + accessibilityLabel: string; + accessibilityText: string; + children: ReactNode; + direction: ResolvedRatingDirection; + disabled: boolean; + focusStyle: StyleProp | undefined; + nativeMax: number; + nativeMin: number; + nativeNow: number; + onAccessibilityAction: (event: AccessibilityActionEvent) => void; + onKeyDown: (event: RatingKeyboardEvent) => void; + orientation: RatingOrientation; + ref: Ref> | undefined; + rootProps: RatingRootProps; + webMax: number; + webMin: number; + webNow: number; +} + +const getWebSliderProps = ({ + accessibilityText, + disabled, + onKeyDown, + orientation, + webMax, + webMin, + webNow, +}: Pick< + InteractiveRootProps, + | "accessibilityText" + | "disabled" + | "onKeyDown" + | "orientation" + | "webMax" + | "webMin" + | "webNow" +>): WebSliderProps | undefined => + Platform.OS === "web" + ? { + "aria-orientation": orientation, + "aria-valuemax": webMax, + "aria-valuemin": webMin, + "aria-valuenow": webNow, + "aria-valuetext": accessibilityText, + ...(disabled ? {} : { onKeyDown }), + role: "slider", + tabIndex: disabled ? -1 : 0, + } + : undefined; + +export const InteractiveRoot = ({ + accessibilityLabel, + accessibilityText, + children, + direction, + disabled, + focusStyle, + nativeMax, + nativeMin, + nativeNow, + onAccessibilityAction, + onKeyDown, + orientation, + ref, + rootProps, + webMax, + webMin, + webNow, +}: InteractiveRootProps) => { + const { onBlur, onFocus, style, testID, ...viewProps } = + getForwardedRatingRootProps(rootProps); + const [focused, setFocused] = useState(false); + const handleFocus = useCallback( + (event: FocusEvent): void => { + setFocused(true); + onFocus?.(event); + }, + [onFocus] + ); + const handleBlur = useCallback( + (event: FocusEvent): void => { + setFocused(false); + onBlur?.(event); + }, + [onBlur] + ); + const webSliderProps = getWebSliderProps({ + accessibilityText, + disabled, + onKeyDown, + orientation, + webMax, + webMin, + webNow, + }); + + return ( + + {children} + + ); +}; diff --git a/src/internal/model.ts b/src/internal/model.ts new file mode 100644 index 0000000..d6cd1c9 --- /dev/null +++ b/src/internal/model.ts @@ -0,0 +1,390 @@ +import type { + RatingDirection, + RatingFillOrigin, + RatingOrientation, + ResolvedRatingDirection, +} from "../types"; + +export const DEFAULT_MAX = 5; +export const DEFAULT_SIZE = 28; +export const MAX_ITEMS = 100; +export const MIN_STEP = 0.01; +export const MAX_LAYOUT_VALUE = 1024; + +export interface NumericRatingModel { + max: number; + maxTick: number; + minTick: number; + step: number; + ticksPerItem: number; +} + +interface PositionModel { + direction: ResolvedRatingDirection; + extent: number; + gap: number; + itemCount: number; + itemSize: number; + orientation: RatingOrientation; +} + +const clamp = (value: number, minimum: number, maximum: number): number => + Math.min(Math.max(value, minimum), maximum); + +export const roundValue = (value: number): number => + Math.round(value * 1_000_000) / 1_000_000; + +export const normalizeMax = (max: number): number => { + if (!Number.isFinite(max)) { + return DEFAULT_MAX; + } + + return clamp(Math.trunc(max), 1, MAX_ITEMS); +}; + +export const normalizePositive = (value: number, fallback: number): number => + Number.isFinite(value) && value > 0 + ? Math.min(value, MAX_LAYOUT_VALUE) + : fallback; + +export const normalizeGap = (gap: number): number => + clamp(Number.isFinite(gap) ? gap : 0, 0, MAX_LAYOUT_VALUE); + +export const normalizeStep = (step: number): number => + Number.isFinite(step) ? clamp(step, MIN_STEP, 1) : 1; + +const getStableRatio = (numerator: number, denominator: number): number => + roundValue(numerator / denominator); + +const getLatticeCeilRatio = ( + numerator: number, + denominator: number +): number => { + const quotient = numerator / denominator; + const nearestInteger = Math.round(quotient); + + return roundValue(nearestInteger * denominator) === roundValue(numerator) + ? nearestInteger + : Math.ceil(quotient); +}; + +export const getTicksPerItem = (step: number): number => + getLatticeCeilRatio(1, step); + +export const getValueFromTick = ( + tick: number, + model: Pick +): number => { + const safeTick = clamp(Math.trunc(tick), 0, model.max * model.ticksPerItem); + const fullItems = Math.floor(safeTick / model.ticksPerItem); + const partialTick = safeTick % model.ticksPerItem; + + if (partialTick === 0) { + return fullItems; + } + + return roundValue( + Math.min(model.max, fullItems + Math.min(partialTick * model.step, 1)) + ); +}; + +export const getClosestTick = ( + value: number, + model: Pick +): number => { + if (!Number.isFinite(value)) { + return 0; + } + + const safeValue = clamp(value, 0, model.max); + const fullItems = Math.floor(safeValue); + + if (fullItems >= model.max) { + return model.max * model.ticksPerItem; + } + + const fraction = safeValue - fullItems; + const regularTick = clamp( + Math.round(getStableRatio(fraction, model.step)), + 0, + model.ticksPerItem - 1 + ); + const regularFraction = regularTick * model.step; + const useItemEnd = 1 - fraction <= Math.abs(regularFraction - fraction); + + return useItemEnd + ? (fullItems + 1) * model.ticksPerItem + : fullItems * model.ticksPerItem + regularTick; +}; + +export const getCeilTick = ( + value: number, + model: Pick +): number => { + if (!Number.isFinite(value) || value <= 0) { + return 0; + } + + const safeValue = clamp(value, 0, model.max); + const fullItems = Math.floor(safeValue); + + if (fullItems >= model.max) { + return model.max * model.ticksPerItem; + } + + const fraction = safeValue - fullItems; + + if (fraction === 0) { + return fullItems * model.ticksPerItem; + } + + const partialTick = getLatticeCeilRatio(fraction, model.step); + + return partialTick >= model.ticksPerItem + ? (fullItems + 1) * model.ticksPerItem + : fullItems * model.ticksPerItem + partialTick; +}; + +export const createNumericModel = ( + max: number, + min: number, + step: number +): NumericRatingModel => { + const safeMax = normalizeMax(max); + const safeStep = normalizeStep(step); + const ticksPerItem = getTicksPerItem(safeStep); + const baseModel = { + max: safeMax, + step: safeStep, + ticksPerItem, + }; + const maxTick = safeMax * ticksPerItem; + const minTick = clamp(Math.max(1, getCeilTick(min, baseModel)), 1, maxTick); + + return { + ...baseModel, + maxTick, + minTick, + }; +}; + +export const normalizeNumericTick = ( + value: number, + model: NumericRatingModel +): number => { + if (!Number.isFinite(value) || value <= 0) { + return 0; + } + + return clamp(getClosestTick(value, model), model.minTick, model.maxTick); +}; + +export const normalizeNumericValue = ( + value: number, + model: NumericRatingModel +): number => getValueFromTick(normalizeNumericTick(value, model), model); + +export const normalizeDisplayValue = ( + value: number, + max: number, + step?: number +): number => { + if (!Number.isFinite(value)) { + return 0; + } + + const safeValue = clamp(value, 0, max); + + if (step === undefined) { + return roundValue(safeValue); + } + + const safeStep = normalizeStep(step); + const displayModel = { + max, + step: safeStep, + ticksPerItem: getTicksPerItem(safeStep), + }; + + return getValueFromTick( + getClosestTick(safeValue, displayModel), + displayModel + ); +}; + +export const getItemFill = (value: number, index: number): number => + roundValue(clamp(value - index, 0, 1)); + +export const getTickItemIndex = (tick: number, ticksPerItem: number): number => + tick <= 0 ? -1 : Math.max(0, Math.ceil(tick / ticksPerItem) - 1); + +export const resolveDirection = ( + direction: RatingDirection, + applicationIsRTL: boolean +): ResolvedRatingDirection => { + if (direction !== "auto") { + return direction; + } + + return applicationIsRTL ? "rtl" : "ltr"; +}; + +export const getFillOrigin = ( + orientation: RatingOrientation, + direction: ResolvedRatingDirection +): RatingFillOrigin => { + if (orientation === "vertical") { + return "bottom"; + } + + return direction === "rtl" ? "right" : "left"; +}; + +export const getTrackExtent = ( + itemCount: number, + itemSize: number, + gap: number +): number => { + const safeItemCount = clamp( + Number.isFinite(itemCount) ? Math.trunc(itemCount) : 0, + 0, + MAX_ITEMS + ); + const safeItemSize = clamp( + Number.isFinite(itemSize) ? itemSize : 0, + 0, + MAX_LAYOUT_VALUE + ); + const safeGap = normalizeGap(gap); + + return ( + safeItemCount * safeItemSize + Math.max(0, safeItemCount - 1) * safeGap + ); +}; + +const getLogicalPosition = ( + localPosition: number, + model: PositionModel +): number => { + const safePosition = clamp( + Number.isFinite(localPosition) ? localPosition : 0, + 0, + model.extent + ); + + const reversedAxis = + model.orientation === "vertical" || + (model.orientation === "horizontal" && model.direction === "rtl"); + + return reversedAxis ? model.extent - safePosition : safePosition; +}; + +export const getNumericTickFromPosition = ( + localPosition: number, + positionModel: PositionModel, + ratingModel: NumericRatingModel +): number => { + const logicalPosition = getLogicalPosition(localPosition, positionModel); + + if (logicalPosition <= 0) { + return ratingModel.minTick; + } + + if (logicalPosition >= positionModel.extent) { + return ratingModel.maxTick; + } + + const stride = positionModel.itemSize + positionModel.gap; + let itemIndex = Math.floor(logicalPosition / stride); + let itemPosition = logicalPosition - itemIndex * stride; + + if (positionModel.gap === 0 && itemPosition === 0 && logicalPosition > 0) { + itemIndex -= 1; + itemPosition = positionModel.itemSize; + } + + itemIndex = clamp(itemIndex, 0, positionModel.itemCount - 1); + + if (itemPosition > positionModel.itemSize) { + const gapPosition = itemPosition - positionModel.itemSize; + const previousItemEnd = (itemIndex + 1) * ratingModel.ticksPerItem; + const nextItemStart = Math.min(ratingModel.maxTick, previousItemEnd + 1); + const gapTick = + gapPosition < positionModel.gap / 2 ? previousItemEnd : nextItemStart; + + return clamp(gapTick, ratingModel.minTick, ratingModel.maxTick); + } + + const fraction = clamp(itemPosition / positionModel.itemSize, 0, 1); + const partialTick = clamp( + Math.max(1, getLatticeCeilRatio(fraction, ratingModel.step)), + 1, + ratingModel.ticksPerItem + ); + const tick = itemIndex * ratingModel.ticksPerItem + partialTick; + + return clamp(tick, ratingModel.minTick, ratingModel.maxTick); +}; + +export const getScaleTickFromPosition = ( + localPosition: number, + model: PositionModel +): number => { + const logicalPosition = getLogicalPosition(localPosition, model); + + if (logicalPosition <= 0) { + return 1; + } + + if (logicalPosition >= model.extent) { + return model.itemCount; + } + + const stride = model.itemSize + model.gap; + let visualIndex = Math.floor(logicalPosition / stride); + const itemPosition = logicalPosition - visualIndex * stride; + + if (itemPosition > model.itemSize) { + const gapPosition = itemPosition - model.itemSize; + + if (gapPosition >= model.gap / 2) { + visualIndex += 1; + } + } + + visualIndex = clamp(visualIndex, 0, model.itemCount - 1); + + return visualIndex + 1; +}; + +export const getIncrementTick = ( + currentTick: number, + minTick: number, + maxTick: number +): number => + currentTick <= 0 + ? minTick + : Math.min(maxTick, Math.max(minTick, currentTick + 1)); + +export const getDecrementTick = ( + currentTick: number, + minTick: number, + allowClear: boolean +): number => { + if (currentTick <= 0) { + return 0; + } + + if (currentTick <= minTick) { + return allowClear ? 0 : minTick; + } + + return Math.max(minTick, currentTick - 1); +}; + +export const getHomeTick = (minTick: number, allowClear: boolean): number => + allowClear ? 0 : minTick; + +export const isRatingScaleValue = (value: unknown): value is number | string => + typeof value === "string" || + (typeof value === "number" && Number.isFinite(value)); diff --git a/src/internal/rating-track.tsx b/src/internal/rating-track.tsx new file mode 100644 index 0000000..ea0446d --- /dev/null +++ b/src/internal/rating-track.tsx @@ -0,0 +1,610 @@ +import { memo } from "react"; +import type { ReactNode } from "react"; +import { Animated, Platform, StyleSheet, Text, View } from "react-native"; +import type { ColorValue, ViewProps, ViewStyle } from "react-native"; + +import type { + RatingInteractionMode, + RatingOrientation, + RatingRenderItem, + RatingScaleItem, + RatingScaleRenderItem, + RatingScaleSelectionMode, + RatingScaleValue, + ResolvedRatingDirection, +} from "../types"; +import { getFillOrigin, getItemFill, getTrackExtent } from "./model"; +import type { NumericRatingModel } from "./model"; + +export const DEFAULT_ACTIVE_COLOR = "#B45309"; +export const DEFAULT_INACTIVE_COLOR = "#6B7280"; + +const styles = StyleSheet.create({ + activeStar: { + position: "absolute", + start: 0, + }, + animatedItem: { + alignItems: "center", + justifyContent: "center", + }, + fillMask: { + overflow: "hidden", + position: "absolute", + start: 0, + }, + icon: { + overflow: "hidden", + }, + item: { + alignItems: "center", + justifyContent: "center", + }, + pressed: { + transform: [{ scale: 0.94 }], + }, + scaleSelected: { + borderRadius: 999, + borderWidth: 2, + }, + scaleText: { + includeFontPadding: false, + textAlign: "center", + textAlignVertical: "center", + }, + star: { + includeFontPadding: false, + textAlign: "center", + textAlignVertical: "center", + }, + track: { + alignItems: "center", + justifyContent: "center", + }, + webTrackBoxOnly: { + pointerEvents: "box-only", + }, + webTrackNone: { + pointerEvents: "none", + }, +}); + +interface RatingTrackFrameProps { + children: ReactNode; + direction: ResolvedRatingDirection; + gap: number; + handlers?: ViewProps | undefined; + interactionMode?: RatingInteractionMode | undefined; + interactive: boolean; + itemCount: number; + itemSize: number; + orientation: RatingOrientation; + targetSize: number; + testID?: string | undefined; +} + +interface DefaultRatingIconProps { + activeColor: ColorValue; + direction: ResolvedRatingDirection; + fill: number; + inactiveColor: ColorValue; + orientation: RatingOrientation; + size: number; +} + +interface NumericRatingItemProps { + activeColor: ColorValue; + direction: ResolvedRatingDirection; + disabled: boolean; + fill: number; + inactiveColor: ColorValue; + index: number; + itemSize: number; + orientation: RatingOrientation; + pressed: boolean; + pulseScale: Animated.Value | null; + renderItem: RatingRenderItem | undefined; + targetSize: number; + testID: string | undefined; +} + +interface NumericRatingItemsProps { + activeColor: ColorValue; + direction: ResolvedRatingDirection; + disabled: boolean; + inactiveColor: ColorValue; + itemSize: number; + model: NumericRatingModel; + orientation: RatingOrientation; + pressedTick: number | null; + pulseIndex: number | null; + pulseScale: Animated.Value | null; + renderItem: RatingRenderItem | undefined; + targetSize: number; + testID: string | undefined; + value: number; +} + +interface ScaleRatingItemProps { + activeColor: ColorValue; + direction: ResolvedRatingDirection; + disabled: boolean; + inactiveColor: ColorValue; + index: number; + item: RatingScaleItem; + itemExtent: number; + itemSize: number; + orientation: RatingOrientation; + pressed: boolean; + pulseScale: Animated.Value | null; + renderItem: RatingScaleRenderItem | undefined; + selected: boolean; + targetSize: number; + testID: string | undefined; +} + +interface ScaleRatingItemsProps { + activeColor: ColorValue; + direction: ResolvedRatingDirection; + disabled: boolean; + inactiveColor: ColorValue; + itemExtent: number; + itemSize: number; + items: readonly RatingScaleItem[]; + orientation: RatingOrientation; + pressedTick: number | null; + pulseIndex: number | null; + pulseScale: Animated.Value | null; + renderItem: RatingScaleRenderItem | undefined; + reversed: boolean; + selectedTick: number; + selectionMode: RatingScaleSelectionMode; + targetSize: number; + testID: string | undefined; +} + +interface WebTrackStyle extends ViewStyle { + touchAction?: "pan-x" | "pan-y"; + userSelect: "none"; +} + +const getItemDimensions = ( + orientation: RatingOrientation, + itemSize: number, + targetSize: number +): ViewStyle => + orientation === "horizontal" + ? { height: targetSize, width: itemSize } + : { height: itemSize, width: targetSize }; + +const getWebTrackStyle = ( + interactive: boolean, + interactionMode: RatingInteractionMode | undefined, + orientation: RatingOrientation +): WebTrackStyle | undefined => { + if (Platform.OS !== "web") { + return undefined; + } + + const style: WebTrackStyle = { userSelect: "none" }; + + if (interactive && interactionMode === "tap-and-drag") { + style.touchAction = orientation === "horizontal" ? "pan-y" : "pan-x"; + } + + return style; +}; + +export const RatingTrackFrame = ({ + children, + direction, + gap, + handlers, + interactionMode, + interactive, + itemCount, + itemSize, + orientation, + targetSize, + testID, +}: RatingTrackFrameProps) => { + const extent = getTrackExtent(itemCount, itemSize, gap); + const dimensions = + orientation === "horizontal" + ? { height: targetSize, width: extent } + : { height: extent, width: targetSize }; + const layout = + orientation === "horizontal" + ? { columnGap: gap, flexDirection: "row" as const } + : { flexDirection: "column-reverse" as const, rowGap: gap }; + let nativePointerEvents: ViewProps["pointerEvents"]; + + if (Platform.OS !== "web") { + nativePointerEvents = interactive ? "box-only" : "none"; + } + + let webPointerStyle: ViewProps["style"]; + + if (Platform.OS === "web") { + webPointerStyle = interactive + ? styles.webTrackBoxOnly + : styles.webTrackNone; + } + + return ( + + {children} + + ); +}; + +const DefaultRatingIcon = ({ + activeColor, + direction, + fill, + inactiveColor, + orientation, + size, +}: DefaultRatingIconProps) => { + const textStyle = [ + styles.star, + { + color: inactiveColor, + fontSize: size, + height: size, + lineHeight: size, + width: size, + }, + ]; + const maskSize = + orientation === "horizontal" + ? { height: size, width: size * fill } + : { height: size * fill, width: size }; + const maskOrigin = orientation === "vertical" ? { bottom: 0 } : { top: 0 }; + const wholeIcon = fill === 0 || fill === 1; + + return ( + + {wholeIcon ? ( + + {fill === 1 ? "β˜…" : "β˜†"} + + ) : ( + <> + + β˜† + + + + β˜… + + + + )} + + ); +}; + +const NumericRatingItem = ({ + activeColor, + direction, + disabled, + fill, + inactiveColor, + index, + itemSize, + orientation, + pressed, + pulseScale, + renderItem, + targetSize, + testID, +}: NumericRatingItemProps) => { + const content = renderItem ? ( + renderItem({ + activeColor, + direction, + disabled, + fill, + fillOrigin: getFillOrigin(orientation, direction), + inactiveColor, + index, + orientation, + pressed, + size: itemSize, + value: index + 1, + }) + ) : ( + + ); + const itemStyle = [ + styles.item, + getItemDimensions(orientation, itemSize, targetSize), + pressed && renderItem === undefined ? styles.pressed : undefined, + ]; + + if (pulseScale !== null) { + return ( + + {content} + + ); + } + + return ( + + {content} + + ); +}; + +const MemoNumericRatingItem = memo(NumericRatingItem); + +export const NumericRatingItems = ({ + activeColor, + direction, + disabled, + inactiveColor, + itemSize, + model, + orientation, + pressedTick, + pulseIndex, + pulseScale, + renderItem, + targetSize, + testID, + value, +}: NumericRatingItemsProps) => + Array.from({ length: model.max }, (_, index) => { + const pressedIndex = + pressedTick === null || pressedTick <= 0 + ? -1 + : Math.ceil(pressedTick / model.ticksPerItem) - 1; + + return ( + + ); + }); + +const getScaleContent = ( + content: ReactNode | undefined, + label: string, + color: ColorValue, + extent: number, + size: number, + orientation: RatingOrientation, + selected: boolean +): ReactNode => { + const hasContent = + content !== undefined && content !== null && typeof content !== "boolean"; + + if ( + !hasContent || + typeof content === "number" || + typeof content === "string" + ) { + return ( + + {hasContent ? content : label} + + ); + } + + return content; +}; + +const ScaleRatingItem = ({ + activeColor, + direction, + disabled, + inactiveColor, + index, + item, + itemExtent, + itemSize, + orientation, + pressed, + pulseScale, + renderItem, + selected, + targetSize, + testID, +}: ScaleRatingItemProps) => { + const content = renderItem + ? renderItem({ + activeColor, + content: item.content, + direction, + disabled, + inactiveColor, + index, + itemExtent, + label: item.label, + orientation, + pressed, + selected, + size: itemSize, + value: item.value, + }) + : getScaleContent( + item.content, + item.label, + selected ? activeColor : inactiveColor, + itemExtent, + itemSize, + orientation, + selected + ); + const itemStyle = [ + styles.item, + getItemDimensions(orientation, itemExtent, targetSize), + pressed && renderItem === undefined ? styles.pressed : undefined, + selected && renderItem === undefined + ? [styles.scaleSelected, { borderColor: activeColor }] + : undefined, + ]; + + if (pulseScale !== null) { + return ( + + {content} + + ); + } + + return ( + + {content} + + ); +}; + +// React.memo preserves these props at runtime but its declarations erase the +// generic relationship between each scale item and its render callback. +// oxlint-disable-next-line typescript/no-unsafe-type-assertion +const MemoScaleRatingItem = memo(ScaleRatingItem) as typeof ScaleRatingItem; + +export const ScaleRatingItems = ({ + activeColor, + direction, + disabled, + inactiveColor, + itemExtent, + itemSize, + items, + orientation, + pressedTick, + pulseIndex, + pulseScale, + renderItem, + reversed, + selectedTick, + selectionMode, + targetSize, + testID, +}: ScaleRatingItemsProps) => + Array.from({ length: items.length }, (_, visualIndex) => { + const index = reversed ? items.length - 1 - visualIndex : visualIndex; + const item = items[index]; + + if (item === undefined) { + return null; + } + + const itemTick = reversed ? items.length - index : index + 1; + const selected = + selectionMode === "cumulative" + ? selectedTick > 0 && itemTick <= selectedTick + : itemTick === selectedTick; + + return ( + + ); + }); diff --git a/src/internal/reduced-motion.ts b/src/internal/reduced-motion.ts new file mode 100644 index 0000000..d60a096 --- /dev/null +++ b/src/internal/reduced-motion.ts @@ -0,0 +1,85 @@ +import { useSyncExternalStore } from "react"; +import { AccessibilityInfo } from "react-native"; + +type StoreListener = () => void; + +const listeners = new Set(); +let reduceMotion = true; +let requestGeneration = 0; +let nativeSubscription: { remove: () => void } | undefined; + +const notifyListeners = (): void => { + for (const listener of listeners) { + listener(); + } +}; + +const updatePreference = (preference: boolean): void => { + if (reduceMotion === preference) { + return; + } + + reduceMotion = preference; + notifyListeners(); +}; + +const readPreference = async (generation: number): Promise => { + try { + const preference = await AccessibilityInfo.isReduceMotionEnabled(); + + if (generation === requestGeneration && listeners.size > 0) { + updatePreference(preference); + } + } catch { + // A missing platform implementation keeps the conservative reduced default. + } +}; + +const startNativeSubscription = (): void => { + requestGeneration += 1; + const generation = requestGeneration; + + nativeSubscription = AccessibilityInfo.addEventListener( + "reduceMotionChanged", + updatePreference + ); + + void readPreference(generation); +}; + +const stopNativeSubscription = (): void => { + requestGeneration += 1; + nativeSubscription?.remove(); + nativeSubscription = undefined; + reduceMotion = true; +}; + +const subscribe = (listener: StoreListener): (() => void) => { + listeners.add(listener); + + if (listeners.size === 1) { + startNativeSubscription(); + } + + return () => { + listeners.delete(listener); + + if (listeners.size === 0) { + stopNativeSubscription(); + } + }; +}; + +const unsubscribeDisabled = (): void => { + // No store subscription is created when motion is disabled. +}; +const subscribeDisabled = (): (() => void) => unsubscribeDisabled; +const getSnapshot = (): boolean => reduceMotion; +const getReducedSnapshot = (): boolean => true; + +export const useReducedMotion = (enabled: boolean): boolean => + useSyncExternalStore( + enabled ? subscribe : subscribeDisabled, + enabled ? getSnapshot : getReducedSnapshot, + getReducedSnapshot + ); diff --git a/src/internal/root-props.ts b/src/internal/root-props.ts new file mode 100644 index 0000000..03f791b --- /dev/null +++ b/src/internal/root-props.ts @@ -0,0 +1,66 @@ +import type { RatingRootProps } from "../types"; + +const OWNED_ROOT_PROPS = new Set([ + "accessible", + "accessibilityActions", + "accessibilityElementsHidden", + "accessibilityRole", + "accessibilityState", + "accessibilityValue", + "aria-disabled", + "aria-hidden", + "aria-label", + "aria-orientation", + "aria-valuemax", + "aria-valuemin", + "aria-valuenow", + "aria-valuetext", + "children", + "focusable", + "importantForAccessibility", + "onAccessibilityAction", + "onKeyDown", + "onKeyDownCapture", + "onMoveShouldSetResponder", + "onMoveShouldSetResponderCapture", + "onResponderEnd", + "onResponderGrant", + "onResponderMove", + "onResponderReject", + "onResponderRelease", + "onResponderStart", + "onResponderTerminate", + "onResponderTerminationRequest", + "onShouldBlockNativeResponder", + "onStartShouldSetResponder", + "onStartShouldSetResponderCapture", + "pointerEvents", + "role", + "tabIndex", +]); + +const hasOwnProperty = (value: object, name: PropertyKey): boolean => + Object.getOwnPropertyDescriptor(value, name) !== undefined; + +export const getForwardedRatingRootProps = ( + rootProps: RatingRootProps +): RatingRootProps => { + let hasOwnedProp = false; + + for (const name in rootProps) { + if (hasOwnProperty(rootProps, name) && OWNED_ROOT_PROPS.has(name)) { + hasOwnedProp = true; + break; + } + } + + if (!hasOwnedProp) { + return rootProps; + } + + const entries = Object.entries(rootProps).filter( + ([name]) => !OWNED_ROOT_PROPS.has(name) + ); + + return Object.fromEntries(entries); +}; diff --git a/src/internal/use-rating-interaction.ts b/src/internal/use-rating-interaction.ts new file mode 100644 index 0000000..454dedf --- /dev/null +++ b/src/internal/use-rating-interaction.ts @@ -0,0 +1,802 @@ +import { useCallback, useLayoutEffect, useRef, useState } from "react"; +import type { + AccessibilityActionEvent, + GestureResponderEvent, + LayoutChangeEvent, + ViewProps, +} from "react-native"; + +import type { + RatingInteractionDetails, + RatingInteractionEndDetails, + RatingInteractionMode, + RatingInteractionSource, + RatingOrientation, +} from "../types"; +import { getDecrementTick, getHomeTick, getIncrementTick } from "./model"; + +const TOUCH_SLOP = 7; +const AXIS_DOMINANCE_RATIO = 1.25; + +const POINTER_DETAILS: RatingInteractionDetails = Object.freeze({ + source: "pointer", +}); +const KEYBOARD_DETAILS: RatingInteractionDetails = Object.freeze({ + source: "keyboard", +}); +const ACCESSIBILITY_DETAILS: RatingInteractionDetails = Object.freeze({ + source: "accessibility", +}); + +type GesturePhase = + | "cancelled" + | "committing" + | "dragging" + | "idle" + | "pressed"; + +interface GestureState { + getValue: ((tick: number) => Value) | null; + lastEmittedTick: number; + lastTick: number; + origin: number; + phase: GesturePhase; + startCrossAxis: number; + startPrimaryAxis: number; + startTick: number; +} + +interface InteractionVisualState { + active: boolean; + dragging: boolean; + tick: number | null; +} + +interface RatingKeyboardEvent { + altKey?: boolean | undefined; + ctrlKey?: boolean | undefined; + key?: string | undefined; + metaKey?: boolean | undefined; + nativeEvent?: + | { + altKey?: boolean | undefined; + ctrlKey?: boolean | undefined; + key?: string | undefined; + metaKey?: boolean | undefined; + } + | undefined; + preventDefault?: (() => void) | undefined; + stopPropagation?: (() => void) | undefined; +} + +interface UseRatingInteractionOptions { + allowClear: boolean; + currentTick: number; + defaultExtent: number; + disabled: boolean; + getValue: (tick: number) => Value; + interactionMode: RatingInteractionMode; + maxTick: number; + minTick: number; + onChangeEnd: + | ((value: Value, details: RatingInteractionEndDetails) => void) + | undefined; + onChangeTick: (tick: number) => void; + onComplete: (tick: number, source: RatingInteractionSource) => void; + onInteractionStart: + | ((value: Value, details: RatingInteractionDetails) => void) + | undefined; + orientation: RatingOrientation; + positionToTick: (position: number, extent: number) => number; + structure: readonly unknown[]; +} + +type RatingTrackHandlers = Pick< + ViewProps, + | "onLayout" + | "onResponderEnd" + | "onResponderGrant" + | "onResponderMove" + | "onResponderRelease" + | "onResponderStart" + | "onResponderTerminate" + | "onResponderTerminationRequest" + | "onStartShouldSetResponder" +>; + +interface UseRatingInteractionResult { + active: boolean; + dragging: boolean; + draftTick: number | null; + handleAccessibilityAction: (event: AccessibilityActionEvent) => void; + handleKeyDown: (event: RatingKeyboardEvent) => void; + trackHandlers: RatingTrackHandlers; +} + +const createIdleGesture = (): GestureState => ({ + getValue: null, + lastEmittedTick: 0, + lastTick: 0, + origin: 0, + phase: "idle", + startCrossAxis: 0, + startPrimaryAxis: 0, + startTick: 0, +}); + +const areStructuresEqual = ( + first: readonly unknown[], + second: readonly unknown[] +): boolean => + first.length === second.length && + first.every((value, index) => Object.is(value, second[index])); + +const getAxisCoordinates = ( + event: GestureResponderEvent, + orientation: RatingOrientation +): { + crossAxis: number; + location: number; + page: number; +} => { + const { nativeEvent } = event; + const horizontal = orientation === "horizontal"; + const location = horizontal ? nativeEvent.locationX : nativeEvent.locationY; + const crossLocation = horizontal + ? nativeEvent.locationY + : nativeEvent.locationX; + const rawPage = horizontal ? nativeEvent.pageX : nativeEvent.pageY; + const rawCrossPage = horizontal ? nativeEvent.pageY : nativeEvent.pageX; + const safeLocation = Number.isFinite(location) ? location : 0; + const safeCrossLocation = Number.isFinite(crossLocation) ? crossLocation : 0; + + return { + crossAxis: Number.isFinite(rawCrossPage) ? rawCrossPage : safeCrossLocation, + location: safeLocation, + page: Number.isFinite(rawPage) ? rawPage : safeLocation, + }; +}; + +const getTouchCount = (event?: GestureResponderEvent): number => { + const touches = event?.nativeEvent.touches; + return Array.isArray(touches) ? touches.length : 1; +}; + +const getDetails = ( + source: RatingInteractionSource +): RatingInteractionDetails => { + if (source === "accessibility") { + return ACCESSIBILITY_DETAILS; + } + + return source === "keyboard" ? KEYBOARD_DETAILS : POINTER_DETAILS; +}; + +export const useRatingInteraction = ({ + allowClear, + currentTick, + defaultExtent, + disabled, + getValue, + interactionMode, + maxTick, + minTick, + onChangeEnd, + onChangeTick, + onComplete, + onInteractionStart, + orientation, + positionToTick, + structure, +}: UseRatingInteractionOptions): UseRatingInteractionResult => { + const latest = useRef>({ + allowClear, + currentTick, + defaultExtent, + disabled, + getValue, + interactionMode, + maxTick, + minTick, + onChangeEnd, + onChangeTick, + onComplete, + onInteractionStart, + orientation, + positionToTick, + structure, + }); + useLayoutEffect(() => { + latest.current = { + allowClear, + currentTick, + defaultExtent, + disabled, + getValue, + interactionMode, + maxTick, + minTick, + onChangeEnd, + onChangeTick, + onComplete, + onInteractionStart, + orientation, + positionToTick, + structure, + }; + }, [ + allowClear, + currentTick, + defaultExtent, + disabled, + getValue, + interactionMode, + maxTick, + minTick, + onChangeEnd, + onChangeTick, + onComplete, + onInteractionStart, + orientation, + positionToTick, + structure, + ]); + + const extent = useRef(defaultExtent); + useLayoutEffect(() => { + extent.current = defaultExtent; + }, [defaultExtent]); + + const gesture = useRef>(createIdleGesture()); + const mounted = useRef(true); + const visual = useRef({ + active: false, + dragging: false, + tick: null, + }); + const [visualState, setVisualState] = useState({ + active: false, + dragging: false, + tick: null, + }); + + const updateVisual = useCallback((next: InteractionVisualState): void => { + const previous = visual.current; + + if ( + previous.active === next.active && + previous.dragging === next.dragging && + previous.tick === next.tick + ) { + return; + } + + visual.current = next; + setVisualState(next); + }, []); + + const resetInteraction = useCallback((): void => { + gesture.current = createIdleGesture(); + updateVisual({ active: false, dragging: false, tick: null }); + }, [updateVisual]); + + const isCurrentGesture = useCallback( + ( + candidate: GestureState, + phase: "committing" | "dragging" + ): boolean => + mounted.current && + gesture.current === candidate && + candidate.phase === phase, + [] + ); + + const emitTick = useCallback((tick: number): void => { + const currentGesture = gesture.current; + + if (currentGesture.lastEmittedTick === tick) { + currentGesture.lastTick = tick; + return; + } + + currentGesture.lastEmittedTick = tick; + currentGesture.lastTick = tick; + latest.current.onChangeTick(tick); + }, []); + + const beginInteraction = useCallback( + ( + source: RatingInteractionSource, + tick: number, + valueFromTick: (tick: number) => Value = latest.current.getValue + ): void => { + latest.current.onInteractionStart?.( + valueFromTick(tick), + getDetails(source) + ); + }, + [] + ); + + const endInteraction = useCallback( + ( + source: RatingInteractionSource, + tick: number, + cancelled: boolean, + valueFromTick: (tick: number) => Value = latest.current.getValue + ): void => { + latest.current.onChangeEnd?.(valueFromTick(tick), { + cancelled, + source, + }); + + if (!cancelled && mounted.current) { + latest.current.onComplete(tick, source); + } + }, + [] + ); + + const cancelPointerInteraction = useCallback( + (updateVisualState = true): void => { + const currentGesture = gesture.current; + + if ( + currentGesture.phase === "idle" || + currentGesture.phase === "cancelled" + ) { + return; + } + + const accepted = + currentGesture.phase === "committing" || + currentGesture.phase === "dragging"; + const finalTick = currentGesture.lastTick; + const valueFromTick = currentGesture.getValue ?? latest.current.getValue; + currentGesture.phase = "cancelled"; + if (updateVisualState) { + updateVisual({ active: false, dragging: false, tick: null }); + } + + if (accepted) { + endInteraction("pointer", finalTick, true, valueFromTick); + } + }, + [endInteraction, updateVisual] + ); + + useLayoutEffect(() => { + mounted.current = true; + + return () => { + mounted.current = false; + cancelPointerInteraction(false); + }; + }, [cancelPointerInteraction]); + + const previousStructure = useRef(structure); + useLayoutEffect(() => { + const changed = !areStructuresEqual(previousStructure.current, structure); + previousStructure.current = structure; + + if (changed) { + cancelPointerInteraction(); + } + }, [cancelPointerInteraction, structure]); + + useLayoutEffect(() => { + if (disabled) { + cancelPointerInteraction(); + } + }, [cancelPointerInteraction, disabled]); + + const commitDiscreteTick = useCallback( + (tick: number, source: Exclude) => { + const options = latest.current; + + if (options.disabled) { + return; + } + + const safeTick = Math.min( + options.maxTick, + Math.max(tick === 0 ? 0 : options.minTick, tick) + ); + const startTick = options.currentTick; + beginInteraction(source, startTick); + + if ( + !mounted.current || + latest.current.disabled || + !areStructuresEqual(options.structure, latest.current.structure) + ) { + endInteraction(source, startTick, true, options.getValue); + return; + } + + if (safeTick !== startTick) { + options.onChangeTick(safeTick); + + if ( + !mounted.current || + latest.current.disabled || + !areStructuresEqual(options.structure, latest.current.structure) + ) { + endInteraction(source, safeTick, true, options.getValue); + return; + } + } + + endInteraction(source, safeTick, false, options.getValue); + }, + [beginInteraction, endInteraction] + ); + + const onLayout = useCallback((event: LayoutChangeEvent): void => { + const { layout } = event.nativeEvent; + const measuredExtent = + latest.current.orientation === "horizontal" + ? layout.width + : layout.height; + + extent.current = + Number.isFinite(measuredExtent) && measuredExtent > 0 + ? measuredExtent + : latest.current.defaultExtent; + }, []); + + const onStartShouldSetResponder = useCallback( + (event: GestureResponderEvent): boolean => + !latest.current.disabled && getTouchCount(event) <= 1, + [] + ); + + const onResponderGrant = useCallback( + (event: GestureResponderEvent): void => { + const options = latest.current; + + if (options.disabled || getTouchCount(event) > 1) { + resetInteraction(); + return; + } + + const coordinates = getAxisCoordinates(event, options.orientation); + const candidateTick = options.positionToTick( + coordinates.location, + extent.current + ); + + gesture.current = { + getValue: options.getValue, + lastEmittedTick: options.currentTick, + lastTick: candidateTick, + origin: coordinates.page - coordinates.location, + phase: "pressed", + startCrossAxis: coordinates.crossAxis, + startPrimaryAxis: coordinates.page, + startTick: options.currentTick, + }; + updateVisual({ + active: true, + dragging: false, + tick: candidateTick, + }); + }, + [resetInteraction, updateVisual] + ); + + const onResponderMove = useCallback( + (event: GestureResponderEvent): void => { + const currentGesture = gesture.current; + + if ( + currentGesture.phase === "idle" || + currentGesture.phase === "cancelled" + ) { + return; + } + + const options = latest.current; + + if (options.disabled || getTouchCount(event) > 1) { + cancelPointerInteraction(); + return; + } + + const coordinates = getAxisCoordinates(event, options.orientation); + const primaryDistance = Math.abs( + coordinates.page - currentGesture.startPrimaryAxis + ); + const crossDistance = Math.abs( + coordinates.crossAxis - currentGesture.startCrossAxis + ); + + if (currentGesture.phase === "pressed") { + if (options.interactionMode === "tap") { + if (Math.max(primaryDistance, crossDistance) > TOUCH_SLOP) { + currentGesture.phase = "cancelled"; + updateVisual({ active: false, dragging: false, tick: null }); + } + + return; + } + + if ( + crossDistance > TOUCH_SLOP && + crossDistance > primaryDistance * AXIS_DOMINANCE_RATIO + ) { + currentGesture.phase = "cancelled"; + updateVisual({ active: false, dragging: false, tick: null }); + return; + } + + if ( + primaryDistance <= TOUCH_SLOP || + primaryDistance <= crossDistance * AXIS_DOMINANCE_RATIO + ) { + return; + } + + currentGesture.phase = "dragging"; + beginInteraction( + "pointer", + currentGesture.startTick, + currentGesture.getValue ?? options.getValue + ); + + if (!isCurrentGesture(currentGesture, "dragging")) { + return; + } + } + + const localPosition = coordinates.page - currentGesture.origin; + const nextTick = options.positionToTick(localPosition, extent.current); + currentGesture.lastTick = nextTick; + updateVisual({ active: true, dragging: true, tick: nextTick }); + emitTick(nextTick); + }, + [ + beginInteraction, + cancelPointerInteraction, + emitTick, + isCurrentGesture, + updateVisual, + ] + ); + + const onResponderStart = useCallback( + (event: GestureResponderEvent): void => { + if (getTouchCount(event) > 1) { + cancelPointerInteraction(); + } + }, + [cancelPointerInteraction] + ); + + const onResponderEnd = useCallback( + (event: GestureResponderEvent): void => { + if (getTouchCount(event) > 1) { + cancelPointerInteraction(); + } + }, + [cancelPointerInteraction] + ); + + const onResponderRelease = useCallback( + (event: GestureResponderEvent): void => { + const currentGesture = gesture.current; + + if ( + currentGesture.phase === "idle" || + currentGesture.phase === "cancelled" + ) { + resetInteraction(); + return; + } + + const options = latest.current; + + if (options.disabled) { + cancelPointerInteraction(); + resetInteraction(); + return; + } + + const coordinates = getAxisCoordinates(event, options.orientation); + const primaryDistance = Math.abs( + coordinates.page - currentGesture.startPrimaryAxis + ); + const crossDistance = Math.abs( + coordinates.crossAxis - currentGesture.startCrossAxis + ); + const finalPointerTick = options.positionToTick( + coordinates.page - currentGesture.origin, + extent.current + ); + const valueFromTick = currentGesture.getValue ?? options.getValue; + + if (currentGesture.phase === "pressed") { + const movedBeyondSlop = + Math.max(primaryDistance, crossDistance) > TOUCH_SLOP; + + if (movedBeyondSlop) { + const primaryDominant = + primaryDistance > crossDistance * AXIS_DOMINANCE_RATIO; + + if (options.interactionMode === "tap" || !primaryDominant) { + resetInteraction(); + return; + } + + currentGesture.lastTick = finalPointerTick; + currentGesture.phase = "dragging"; + beginInteraction("pointer", currentGesture.startTick, valueFromTick); + + if (!isCurrentGesture(currentGesture, "dragging")) { + return; + } + + emitTick(finalPointerTick); + + if (!isCurrentGesture(currentGesture, "dragging")) { + return; + } + + resetInteraction(); + endInteraction("pointer", finalPointerTick, false, valueFromTick); + return; + } + + const finalTick = + options.allowClear && + currentGesture.startTick !== 0 && + finalPointerTick === currentGesture.startTick + ? 0 + : finalPointerTick; + currentGesture.lastTick = finalTick; + currentGesture.phase = "committing"; + beginInteraction("pointer", currentGesture.startTick, valueFromTick); + + if (!isCurrentGesture(currentGesture, "committing")) { + return; + } + + emitTick(finalTick); + + if (!isCurrentGesture(currentGesture, "committing")) { + return; + } + + resetInteraction(); + endInteraction("pointer", finalTick, false, valueFromTick); + return; + } + + currentGesture.lastTick = finalPointerTick; + emitTick(finalPointerTick); + + if (!isCurrentGesture(currentGesture, "dragging")) { + return; + } + + resetInteraction(); + endInteraction("pointer", finalPointerTick, false, valueFromTick); + }, + [ + beginInteraction, + cancelPointerInteraction, + emitTick, + endInteraction, + isCurrentGesture, + resetInteraction, + ] + ); + + const onResponderTerminate = useCallback((): void => { + cancelPointerInteraction(); + resetInteraction(); + }, [cancelPointerInteraction, resetInteraction]); + + const onResponderTerminationRequest = useCallback( + (): boolean => + gesture.current.phase !== "committing" && + gesture.current.phase !== "dragging", + [] + ); + + const handleAccessibilityAction = useCallback( + (event: AccessibilityActionEvent): void => { + const options = latest.current; + const { actionName } = event.nativeEvent; + + if (actionName === "increment") { + commitDiscreteTick( + getIncrementTick( + options.currentTick, + options.minTick, + options.maxTick + ), + "accessibility" + ); + } else if (actionName === "decrement") { + commitDiscreteTick( + getDecrementTick( + options.currentTick, + options.minTick, + options.allowClear + ), + "accessibility" + ); + } + }, + [commitDiscreteTick] + ); + + const handleKeyDown = useCallback( + (event: RatingKeyboardEvent): void => { + const options = latest.current; + const key = event.key ?? event.nativeEvent?.key; + let nextTick: number | undefined; + + if ( + event.altKey === true || + event.ctrlKey === true || + event.metaKey === true || + event.nativeEvent?.altKey === true || + event.nativeEvent?.ctrlKey === true || + event.nativeEvent?.metaKey === true + ) { + return; + } + + if (key === "ArrowRight" || key === "ArrowUp") { + nextTick = getIncrementTick( + options.currentTick, + options.minTick, + options.maxTick + ); + } else if (key === "ArrowDown" || key === "ArrowLeft") { + nextTick = getDecrementTick( + options.currentTick, + options.minTick, + options.allowClear + ); + } else if (key === "Home") { + nextTick = getHomeTick( + options.minTick, + options.allowClear || options.currentTick === 0 + ); + } else if (key === "End") { + nextTick = options.maxTick; + } + + if (nextTick === undefined) { + return; + } + + event.preventDefault?.(); + event.stopPropagation?.(); + commitDiscreteTick(nextTick, "keyboard"); + }, + [commitDiscreteTick] + ); + + return { + active: visualState.active, + draftTick: visualState.tick, + dragging: visualState.dragging, + handleAccessibilityAction, + handleKeyDown, + trackHandlers: { + onLayout, + onResponderEnd, + onResponderGrant, + onResponderMove, + onResponderRelease, + onResponderStart, + onResponderTerminate, + onResponderTerminationRequest, + onStartShouldSetResponder, + }, + }; +}; diff --git a/src/internal/use-selection-pulse.ts b/src/internal/use-selection-pulse.ts new file mode 100644 index 0000000..9fb287b --- /dev/null +++ b/src/internal/use-selection-pulse.ts @@ -0,0 +1,140 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { Animated, Easing, Platform } from "react-native"; + +interface UseSelectionPulseOptions { + enabled: boolean; + getItemIndex: (tick: number) => number; + mappingKey: number | string; + reduceMotion: boolean; +} + +interface UseSelectionPulseResult { + pulse: (tick: number) => void; + pulseIndex: number | null; + scale: Animated.Value; +} + +interface PulseState { + enabled: boolean; + index: number | null; + mappingKey: number | string; + reduceMotion: boolean; +} + +export const useSelectionPulse = ({ + enabled, + getItemIndex, + mappingKey, + reduceMotion, +}: UseSelectionPulseOptions): UseSelectionPulseResult => { + const latest = useRef({ enabled, getItemIndex, mappingKey, reduceMotion }); + useLayoutEffect(() => { + latest.current = { enabled, getItemIndex, mappingKey, reduceMotion }; + }, [enabled, getItemIndex, mappingKey, reduceMotion]); + + const animation = useRef(null); + const generation = useRef(0); + const scale = useMemo(() => new Animated.Value(1), []); + const [pulseState, setPulseState] = useState(() => ({ + enabled, + index: null, + mappingKey, + reduceMotion, + })); + const previousMappingKey = useRef(mappingKey); + const pulseStateIsCurrent = + pulseState.enabled === enabled && + pulseState.reduceMotion === reduceMotion && + Object.is(pulseState.mappingKey, mappingKey); + + if (!pulseStateIsCurrent) { + setPulseState({ + enabled, + index: null, + mappingKey, + reduceMotion, + }); + } + + const stop = useCallback((): void => { + generation.current += 1; + animation.current?.stop(); + animation.current = null; + scale.setValue(1); + }, [scale]); + + const pulse = useCallback( + (tick: number): void => { + const options = latest.current; + + if (!options.enabled || options.reduceMotion || tick <= 0) { + return; + } + + const itemIndex = options.getItemIndex(tick); + + if (itemIndex < 0) { + return; + } + + generation.current += 1; + const pulseGeneration = generation.current; + animation.current?.stop(); + setPulseState({ + enabled: options.enabled, + index: itemIndex, + mappingKey: options.mappingKey, + reduceMotion: options.reduceMotion, + }); + scale.setValue(0.92); + const nextAnimation = Animated.timing(scale, { + duration: 150, + easing: Easing.out((time) => Easing.cubic(time)), + isInteraction: false, + toValue: 1, + useNativeDriver: Platform.OS !== "web", + }); + animation.current = nextAnimation; + nextAnimation.start(({ finished }) => { + if (finished && generation.current === pulseGeneration) { + animation.current = null; + setPulseState((current) => ({ ...current, index: null })); + } + }); + }, + [scale] + ); + + useEffect(() => { + if (!enabled || reduceMotion) { + stop(); + } + + return () => { + generation.current += 1; + animation.current?.stop(); + animation.current = null; + }; + }, [enabled, reduceMotion, stop]); + + useEffect(() => { + if (!Object.is(previousMappingKey.current, mappingKey)) { + previousMappingKey.current = mappingKey; + stop(); + } + }, [mappingKey, stop]); + + return { + pulse, + pulseIndex: + pulseStateIsCurrent && enabled && !reduceMotion ? pulseState.index : null, + scale, + }; +}; diff --git a/src/rating-display.tsx b/src/rating-display.tsx new file mode 100644 index 0000000..6c25edc --- /dev/null +++ b/src/rating-display.tsx @@ -0,0 +1,119 @@ +import { I18nManager, Platform, StyleSheet, View } from "react-native"; + +import { + createNumericModel, + DEFAULT_MAX, + DEFAULT_SIZE, + normalizeDisplayValue, + normalizeGap, + normalizeMax, + normalizePositive, + resolveDirection, +} from "./internal/model"; +import { + DEFAULT_ACTIVE_COLOR, + DEFAULT_INACTIVE_COLOR, + NumericRatingItems, + RatingTrackFrame, +} from "./internal/rating-track"; +import { getForwardedRatingRootProps } from "./internal/root-props"; +import type { RatingDisplayProps } from "./types"; + +const styles = StyleSheet.create({ + root: { + alignItems: "center", + }, +}); + +export const defaultFormatAccessibilityValue = ( + value: number, + max: number +): string => `${value} out of ${max}`; + +export const RatingDisplay = ({ + accessibilityLabel = "Rating", + activeColor = DEFAULT_ACTIVE_COLOR, + decorative = false, + direction = "auto", + disabled = false, + formatAccessibilityValue = defaultFormatAccessibilityValue, + gap = 0, + inactiveColor = DEFAULT_INACTIVE_COLOR, + max = DEFAULT_MAX, + orientation = "horizontal", + ref, + renderItem, + size = DEFAULT_SIZE, + step, + style, + testID, + value, + ...viewProps +}: RatingDisplayProps) => { + const forwardedViewProps = getForwardedRatingRootProps(viewProps); + const safeMax = normalizeMax(max); + const safeSize = normalizePositive(size, DEFAULT_SIZE); + const safeGap = normalizeGap(gap); + const safeValue = normalizeDisplayValue(value, safeMax, step); + const resolvedDirection = resolveDirection(direction, I18nManager.isRTL); + const valueText = formatAccessibilityValue(safeValue, safeMax); + const model = createNumericModel(safeMax, 0, step ?? 1); + const resolvedAccessibilityLabel = + Platform.OS === "web" + ? `${accessibilityLabel}, ${valueText}` + : accessibilityLabel; + + return ( + + + + + + ); +}; diff --git a/src/rating-scale.tsx b/src/rating-scale.tsx new file mode 100644 index 0000000..4c5d889 --- /dev/null +++ b/src/rating-scale.tsx @@ -0,0 +1,519 @@ +import { useCallback, useMemo, useState } from "react"; +import { I18nManager, Platform, StyleSheet, View } from "react-native"; +import type { ColorValue } from "react-native"; + +import { InteractiveRoot } from "./internal/interactive-root"; +import { + DEFAULT_SIZE, + getScaleTickFromPosition, + getTrackExtent, + isRatingScaleValue, + MAX_ITEMS, + normalizeGap, + normalizePositive, + resolveDirection, +} from "./internal/model"; +import { + DEFAULT_ACTIVE_COLOR, + DEFAULT_INACTIVE_COLOR, + RatingTrackFrame, + ScaleRatingItems, +} from "./internal/rating-track"; +import { useReducedMotion } from "./internal/reduced-motion"; +import { getForwardedRatingRootProps } from "./internal/root-props"; +import { useRatingInteraction } from "./internal/use-rating-interaction"; +import { useSelectionPulse } from "./internal/use-selection-pulse"; +import type { + RatingDirection, + RatingInteractionEndDetails, + RatingInteractionMode, + RatingInteractionSource, + RatingOrientation, + RatingRootProps, + RatingScaleItem, + RatingScaleProps, + RatingScaleRenderItem, + RatingScaleSelectionMode, + RatingScaleValue, +} from "./types"; + +const PRESS_TARGET_SIZE = Platform.select({ default: 48, ios: 44 }); + +const styles = StyleSheet.create({ + root: { + alignItems: "center", + }, +}); + +const defaultFormatAccessibilityValue = ( + item: RatingScaleItem | null +): string => item?.label ?? "No selection"; + +const normalizeItems = ( + items: readonly RatingScaleItem[] +): readonly RatingScaleItem[] => { + const result: RatingScaleItem[] = []; + const seenValues = new Set(); + + for (const item of items) { + if (result.length >= MAX_ITEMS) { + break; + } + + if ( + !isRatingScaleValue(item?.value) || + typeof item.label !== "string" || + item.label.trim().length === 0 || + seenValues.has(item.value) + ) { + continue; + } + + seenValues.add(item.value); + result.push(item); + } + + return result; +}; + +const getScaleTick = ( + value: Value | null, + items: readonly RatingScaleItem[], + reversed: boolean +): number => { + if (value === null) { + return 0; + } + + const index = items.findIndex((item) => item.value === value); + + if (index === -1) { + return 0; + } + + return reversed ? items.length - index : index + 1; +}; + +const getScaleItem = ( + tick: number, + items: readonly RatingScaleItem[], + reversed: boolean +): RatingScaleItem | null => { + const index = reversed ? items.length - tick : tick - 1; + + return items[index] ?? null; +}; + +const getScaleValue = ( + tick: number, + items: readonly RatingScaleItem[], + reversed: boolean +): Value | null => getScaleItem(tick, items, reversed)?.value ?? null; + +interface ScaleDisplayProps { + accessibilityLabel: string; + activeColor: ColorValue; + decorative: boolean; + direction: RatingDirection; + disabled: boolean; + formatAccessibilityValue: (item: RatingScaleItem | null) => string; + gap: number; + inactiveColor: ColorValue; + itemExtent: number; + items: readonly RatingScaleItem[]; + orientation: RatingOrientation; + ref: RatingScaleProps["ref"]; + renderItem: RatingScaleRenderItem | undefined; + reversed: boolean; + rootProps: RatingRootProps; + selectedTick: number; + selectionMode: RatingScaleSelectionMode; + size: number; +} + +interface InteractiveScaleProps< + Value extends RatingScaleValue, +> extends ScaleDisplayProps { + allowClear: boolean; + animated: boolean; + focusStyle: RatingScaleProps["focusStyle"]; + interactionMode: RatingInteractionMode; + onChangeEnd: + | ((value: Value | null, details: RatingInteractionEndDetails) => void) + | undefined; + onChangeTick: (tick: number) => void; + onInteractionStart: RatingScaleProps["onInteractionStart"]; +} + +const ScaleDisplay = ({ + accessibilityLabel, + activeColor, + decorative, + direction, + disabled, + formatAccessibilityValue, + gap, + inactiveColor, + itemExtent, + items, + orientation, + ref, + renderItem, + reversed, + rootProps, + selectedTick, + selectionMode, + size, +}: ScaleDisplayProps) => { + const { style, testID, ...viewProps } = rootProps; + const forwardedViewProps = getForwardedRatingRootProps(viewProps); + const resolvedDirection = resolveDirection(direction, I18nManager.isRTL); + const selectedItem = getScaleItem(selectedTick, items, reversed); + const valueText = formatAccessibilityValue(selectedItem); + const resolvedAccessibilityLabel = + Platform.OS === "web" + ? `${accessibilityLabel}, ${valueText}` + : accessibilityLabel; + + return ( + + + + + + ); +}; + +const InteractiveScale = ({ + accessibilityLabel, + activeColor, + allowClear, + animated, + direction, + disabled, + formatAccessibilityValue, + focusStyle, + gap, + inactiveColor, + interactionMode, + itemExtent, + items, + onChangeEnd, + onChangeTick, + onInteractionStart, + orientation, + ref, + renderItem, + reversed, + rootProps, + selectedTick, + selectionMode, + size, +}: InteractiveScaleProps) => { + const { testID } = rootProps; + const resolvedDirection = resolveDirection(direction, I18nManager.isRTL); + const targetSize = Math.max(size, PRESS_TARGET_SIZE); + const defaultExtent = getTrackExtent(items.length, itemExtent, gap); + const reduceMotion = useReducedMotion(animated && !disabled); + const getValue = useCallback( + (tick: number): Value | null => getScaleValue(tick, items, reversed), + [items, reversed] + ); + const getPulseIndex = useCallback( + (tick: number): number => (reversed ? items.length - tick : tick - 1), + [items.length, reversed] + ); + const pulseMappingKey = useMemo( + () => + JSON.stringify( + items.map(({ value: itemValue }) => [typeof itemValue, itemValue]) + ), + [items] + ); + const { pulse, pulseIndex, scale } = useSelectionPulse({ + enabled: animated && !disabled, + getItemIndex: getPulseIndex, + mappingKey: `${reversed}:${pulseMappingKey}`, + reduceMotion, + }); + const positionToTick = useCallback( + (position: number, extent: number): number => + getScaleTickFromPosition(position, { + direction: resolvedDirection, + extent, + gap, + itemCount: items.length, + itemSize: itemExtent, + orientation, + }), + [gap, itemExtent, items.length, orientation, resolvedDirection] + ); + const interactionStructure = useMemo( + () => [ + allowClear, + gap, + interactionMode, + itemExtent, + orientation, + resolvedDirection, + reversed, + size, + ...items.map((item) => item.value), + ], + [ + allowClear, + gap, + interactionMode, + itemExtent, + items, + orientation, + resolvedDirection, + reversed, + size, + ] + ); + const handleComplete = useCallback( + (tick: number, _source: RatingInteractionSource): void => { + pulse(tick); + }, + [pulse] + ); + const interaction = useRatingInteraction({ + allowClear, + currentTick: selectedTick, + defaultExtent, + disabled, + getValue, + interactionMode, + maxTick: items.length, + minTick: 1, + onChangeEnd, + onChangeTick, + onComplete: handleComplete, + onInteractionStart, + orientation, + positionToTick, + structure: interactionStructure, + }); + const displayTick = interaction.draftTick ?? selectedTick; + const effectiveMinTick = allowClear || displayTick === 0 ? 0 : 1; + const selectedItem = getScaleItem(displayTick, items, reversed); + const accessibilityText = formatAccessibilityValue(selectedItem); + + return ( + + + + + + ); +}; + +export const RatingScale = ({ + accessibilityLabel = "Rating scale", + activeColor = DEFAULT_ACTIVE_COLOR, + allowClear = false, + animated = true, + defaultValue = null, + decorative = false, + direction = "auto", + disabled = false, + formatAccessibilityValue = defaultFormatAccessibilityValue, + focusStyle, + gap = 0, + inactiveColor = DEFAULT_INACTIVE_COLOR, + interactionMode = "tap", + itemExtent, + items, + onChange, + onChangeEnd, + onInteractionStart, + orientation = "horizontal", + readOnly = false, + ref, + renderItem, + reversed = false, + selectionMode = "single", + size = DEFAULT_SIZE, + value, + ...rootProps +}: RatingScaleProps) => { + const safeItems = useMemo(() => normalizeItems(items), [items]); + const controlled = value !== undefined; + const [uncontrolledValue, setUncontrolledValue] = useState(() => + getScaleValue( + getScaleTick(defaultValue, safeItems, reversed), + safeItems, + reversed + ) + ); + const normalizedUncontrolledValue = getScaleValue( + getScaleTick(uncontrolledValue, safeItems, reversed), + safeItems, + reversed + ); + + if (!Object.is(uncontrolledValue, normalizedUncontrolledValue)) { + setUncontrolledValue(normalizedUncontrolledValue); + } + + const selectedTick = getScaleTick( + controlled ? value : normalizedUncontrolledValue, + safeItems, + reversed + ); + const safeSize = normalizePositive(size, DEFAULT_SIZE); + const normalizedItemExtent = normalizePositive( + itemExtent ?? safeSize, + safeSize + ); + const safeItemExtent = + normalizedItemExtent < safeSize ? safeSize : normalizedItemExtent; + const safeGap = normalizeGap(gap); + + const handleTickChange = useCallback( + (tick: number): void => { + const nextValue = getScaleValue(tick, safeItems, reversed); + + if (!controlled) { + setUncontrolledValue(nextValue); + } + + onChange?.(nextValue); + }, + [controlled, onChange, reversed, safeItems] + ); + + const displayProps: ScaleDisplayProps = { + accessibilityLabel, + activeColor, + decorative, + direction, + disabled, + formatAccessibilityValue, + gap: safeGap, + inactiveColor, + itemExtent: safeItemExtent, + items: safeItems, + orientation, + ref, + renderItem, + reversed, + rootProps, + selectedTick, + selectionMode, + size: safeSize, + }; + + if (readOnly || safeItems.length === 0) { + return ; + } + + return ( + + ); +}; diff --git a/src/rating.tsx b/src/rating.tsx index 3a74f98..8bdd65d 100644 --- a/src/rating.tsx +++ b/src/rating.tsx @@ -1,470 +1,233 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import type { ComponentRef, ReactNode, Ref } from "react"; +import { useCallback, useMemo, useState } from "react"; +import { I18nManager, Platform } from "react-native"; +import type { ColorValue } from "react-native"; + +import { InteractiveRoot } from "./internal/interactive-root"; +import { + createNumericModel, + DEFAULT_MAX, + DEFAULT_SIZE, + getNumericTickFromPosition, + getTickItemIndex, + getTrackExtent, + getValueFromTick, + normalizeGap, + normalizeNumericTick, + normalizeNumericValue, + normalizePositive, + resolveDirection, +} from "./internal/model"; +import type { NumericRatingModel } from "./internal/model"; import { - AccessibilityInfo, - Animated, - Easing, - I18nManager, - Platform, - Pressable, - StyleSheet, - Text, - View, - useAnimatedValue, -} from "react-native"; + DEFAULT_ACTIVE_COLOR, + DEFAULT_INACTIVE_COLOR, + NumericRatingItems, + RatingTrackFrame, +} from "./internal/rating-track"; +import { useReducedMotion } from "./internal/reduced-motion"; +import { useRatingInteraction } from "./internal/use-rating-interaction"; +import { useSelectionPulse } from "./internal/use-selection-pulse"; +import { + defaultFormatAccessibilityValue, + RatingDisplay, +} from "./rating-display"; import type { - AccessibilityActionEvent, - ColorValue, - GestureResponderEvent, - ViewProps, -} from "react-native"; + RatingDirection, + RatingInteractionEndDetails, + RatingInteractionMode, + RatingInteractionSource, + RatingOrientation, + RatingProps, + RatingRenderItem, + RatingRootProps, +} from "./types"; -const DEFAULT_ACTIVE_COLOR = "#E8A317"; -const DEFAULT_INACTIVE_COLOR = "#D5D9E0"; -const DEFAULT_MAX = 5; -const DEFAULT_SIZE = 28; -const MAX_ITEMS = 100; -const MIN_STEP = 0.01; const PRESS_TARGET_SIZE = Platform.select({ default: 48, ios: 44 }); -const ACCESSIBILITY_ACTIONS = [ - { name: "increment" as const }, - { name: "decrement" as const }, -]; -const defaultFormatAccessibilityValue = (value: number, max: number) => - `${value} out of ${max}`; - -type RatingRootProps = Omit< - ViewProps, - | "accessibilityActions" - | "accessibilityRole" - | "accessibilityState" - | "accessibilityValue" - | "children" - | "onAccessibilityAction" ->; - -export interface RatingRenderItemProps { - activeColor: ColorValue; - disabled: boolean; - fill: number; - inactiveColor: ColorValue; - index: number; - pressed: boolean; - size: number; - value: number; -} - -export type RatingRenderItem = (props: RatingRenderItemProps) => ReactNode; - -export interface RatingProps extends RatingRootProps { - /** - * Lets a parent own the current rating. Pair it with `onChange`. - */ - value?: number; - /** - * Initial value when the component is uncontrolled. - */ - defaultValue?: number; - /** - * Number of rating items. Values outside 1–100 are clamped. - * @default 5 - */ - max?: number; - /** - * Selection precision. Values outside 0.01–1 are clamped. - * @default 1 - */ - step?: number; - /** - * Called when a user selects a different value. - */ - onChange?: (value: number) => void; - /** - * Clears the rating when its current value is selected again. - * @default false - */ - allowClear?: boolean; - /** - * Prevents interaction and exposes a disabled accessibility state. - * @default false - */ - disabled?: boolean; - /** - * Presents the rating as static content rather than an input. - * @default false - */ - readOnly?: boolean; - /** - * Size of the visible item. Interactive targets remain at least 44pt/48dp. - * @default 28 - */ - size?: number; - /** - * Space between item touch targets. - * @default 0 - */ - gap?: number; - /** - * Color of the selected portion. - * @default "#E8A317" - */ - activeColor?: ColorValue; - /** - * Color of the unselected portion. - * @default "#D5D9E0" - */ - inactiveColor?: ColorValue; - /** - * Enables a quiet selection animation. System reduced-motion settings win. - * @default true - */ - animated?: boolean; - /** - * Formats the text announced with the accessibility value. - */ - formatAccessibilityValue?: (value: number, max: number) => string; - /** - * Replaces the default text star without adding a runtime icon dependency. - */ - renderItem?: RatingRenderItem; - /** - * React 19 ref for the root native View. - */ - ref?: Ref>; -} -interface RatingItemProps { +interface InteractiveNumericRatingProps { + accessibilityLabel: string; activeColor: ColorValue; + allowClear: boolean; animated: boolean; + currentTick: number; + direction: RatingDirection; disabled: boolean; - fill: number; + focusStyle: RatingProps["focusStyle"]; + formatAccessibilityValue: (value: number, max: number) => string; + gap: number; inactiveColor: ColorValue; - index: number; - onPress: (index: number, event: GestureResponderEvent) => void; - readOnly: boolean; - reduceMotion: boolean; - renderItem?: RatingRenderItem | undefined; + interactionMode: RatingInteractionMode; + model: NumericRatingModel; + onChangeEnd: + | ((value: number, details: RatingInteractionEndDetails) => void) + | undefined; + onChangeTick: (tick: number) => void; + onInteractionStart: RatingProps["onInteractionStart"]; + orientation: RatingOrientation; + ref: RatingProps["ref"]; + renderItem: RatingRenderItem | undefined; + rootProps: RatingRootProps; size: number; - targetSize: number; - testID?: string | undefined; } -interface DefaultRatingIconProps { - activeColor: ColorValue; - fill: number; - inactiveColor: ColorValue; - size: number; -} - -const styles = StyleSheet.create({ - activeStar: { - position: "absolute", - top: 0, - }, - activeStarLeft: { - left: 0, - }, - activeStarRight: { - right: 0, - }, - animatedItem: { - alignItems: "center", - justifyContent: "center", - }, - fillMask: { - overflow: "hidden", - position: "absolute", - top: 0, - }, - fillMaskLeft: { - left: 0, - }, - fillMaskRight: { - right: 0, - }, - icon: { - overflow: "hidden", - }, - item: { - alignItems: "center", - justifyContent: "center", - }, - root: { - alignItems: "center", - }, - rootLeftToRight: { - flexDirection: "row", - }, - rootRightToLeft: { - flexDirection: "row-reverse", - }, - star: { - includeFontPadding: false, - textAlign: "center", - textAlignVertical: "center", - }, -}); - -const clamp = (value: number, minimum: number, maximum: number): number => - Math.min(Math.max(value, minimum), maximum); - -const normalizeMax = (max: number): number => { - if (!Number.isFinite(max)) { - return DEFAULT_MAX; - } - - return clamp(Math.trunc(max), 1, MAX_ITEMS); -}; - -const normalizePositive = (value: number, fallback: number): number => - Number.isFinite(value) && value > 0 ? value : fallback; - -const normalizeStep = (step: number): number => - Number.isFinite(step) ? clamp(step, MIN_STEP, 1) : 1; - -const roundValue = (value: number): number => - Math.round(value * 1_000_000) / 1_000_000; - -const getTicksPerItem = (step: number): number => Math.ceil(1 / step); - -const getValueFromTick = (tick: number, max: number, step: number): number => { - const ticksPerItem = getTicksPerItem(step); - const safeTick = clamp(Math.trunc(tick), 0, max * ticksPerItem); - const fullItems = Math.floor(safeTick / ticksPerItem); - const partialTick = safeTick % ticksPerItem; - - if (partialTick === 0) { - return fullItems; - } - - return roundValue(Math.min(max, fullItems + Math.min(partialTick * step, 1))); -}; - -const getClosestTick = (value: number, max: number, step: number): number => { - if (!Number.isFinite(value)) { - return 0; - } - - const safeValue = clamp(value, 0, max); - const ticksPerItem = getTicksPerItem(step); - const fullItems = Math.floor(safeValue); - - if (fullItems >= max) { - return max * ticksPerItem; - } - - const fraction = safeValue - fullItems; - const regularTick = clamp(Math.round(fraction / step), 0, ticksPerItem - 1); - const regularFraction = regularTick * step; - const useItemEnd = 1 - fraction <= Math.abs(regularFraction - fraction); - - return useItemEnd - ? (fullItems + 1) * ticksPerItem - : fullItems * ticksPerItem + regularTick; -}; - -const normalizeValue = (value: number, max: number, step: number): number => { - const tick = getClosestTick(value, max, step); - return getValueFromTick(tick, max, step); -}; - -const getPressedValue = ( - index: number, - event: GestureResponderEvent, - step: number, - targetSize: number -): number => { - const position = event.nativeEvent.locationX; - const physicalFraction = Number.isFinite(position) - ? clamp(position / targetSize, 0, 1) - : 1; - const logicalFraction = I18nManager.isRTL - ? 1 - physicalFraction - : physicalFraction; - const ticksPerItem = getTicksPerItem(step); - const partialTick = clamp( - Math.max(1, Math.ceil(logicalFraction / step)), - 1, - ticksPerItem - ); - - return partialTick === ticksPerItem - ? index + 1 - : roundValue(index + partialTick * step); -}; - -const useReducedMotion = (): boolean => { - const [reduceMotion, setReduceMotion] = useState(true); - - useEffect(() => { - let mounted = true; - const subscription = AccessibilityInfo.addEventListener( - "reduceMotionChanged", - setReduceMotion - ); - - const readPreference = async () => { - const preference = await AccessibilityInfo.isReduceMotionEnabled(); - - if (mounted) { - setReduceMotion(preference); - } - }; - - void readPreference(); - - return () => { - mounted = false; - subscription.remove(); - }; - }, []); - - return reduceMotion; -}; - -const DefaultRatingIcon = ({ - activeColor, - fill, - inactiveColor, - size, -}: DefaultRatingIconProps) => { - const textStyle = [ - styles.star, - { - color: inactiveColor, - fontSize: size, - height: size, - lineHeight: size, - width: size, - }, - ]; - const activeTextStyle = [ - textStyle, - styles.activeStar, - I18nManager.isRTL ? styles.activeStarRight : styles.activeStarLeft, - { color: activeColor }, - ]; - - return ( - - - β˜… - - - - β˜… - - - - ); -}; - -const RatingItem = ({ +const InteractiveNumericRating = ({ + accessibilityLabel, activeColor, + allowClear, animated, + currentTick, + direction, disabled, - fill, + focusStyle, + formatAccessibilityValue, + gap, inactiveColor, - index, - onPress, - readOnly, - reduceMotion, + interactionMode, + model, + onChangeEnd, + onChangeTick, + onInteractionStart, + orientation, + ref, renderItem, + rootProps, size, - targetSize, - testID, -}: RatingItemProps) => { - const scale = useAnimatedValue(1); - const previousFill = useRef(fill); - - useEffect(() => { - const shouldAnimate = - animated && !reduceMotion && fill > previousFill.current; - previousFill.current = fill; - scale.stopAnimation(); - - if (!shouldAnimate) { - scale.setValue(1); - return () => { - scale.stopAnimation(); - }; - } - - scale.setValue(0.88); - const animation = Animated.timing(scale, { - duration: 160, - easing: Easing.out((time) => Easing.cubic(time)), - isInteraction: false, - toValue: 1, - useNativeDriver: true, - }); - animation.start(); - - return () => { - animation.stop(); - }; - }, [animated, fill, reduceMotion, scale]); - - const handlePress = useCallback( - (event: GestureResponderEvent) => { - onPress(index, event); +}: InteractiveNumericRatingProps) => { + const { testID } = rootProps; + const resolvedDirection = resolveDirection(direction, I18nManager.isRTL); + const targetSize = Math.max(size, PRESS_TARGET_SIZE); + const defaultExtent = getTrackExtent(model.max, size, gap); + const reduceMotion = useReducedMotion(animated && !disabled); + const getValue = useCallback( + (tick: number): number => getValueFromTick(tick, model), + [model] + ); + const getPulseIndex = useCallback( + (tick: number): number => getTickItemIndex(tick, model.ticksPerItem), + [model.ticksPerItem] + ); + const { pulse, pulseIndex, scale } = useSelectionPulse({ + enabled: animated && !disabled, + getItemIndex: getPulseIndex, + mappingKey: `${model.max}:${model.ticksPerItem}`, + reduceMotion, + }); + const positionToTick = useCallback( + (position: number, extent: number): number => + getNumericTickFromPosition( + position, + { + direction: resolvedDirection, + extent, + gap, + itemCount: model.max, + itemSize: size, + orientation, + }, + model + ), + [gap, model, orientation, resolvedDirection, size] + ); + const interactionStructure = useMemo( + () => [ + allowClear, + gap, + interactionMode, + model.max, + model.minTick, + model.step, + model.ticksPerItem, + orientation, + resolvedDirection, + size, + ], + [ + allowClear, + gap, + interactionMode, + model, + orientation, + resolvedDirection, + size, + ] + ); + const handleComplete = useCallback( + (tick: number, _source: RatingInteractionSource): void => { + pulse(tick); }, - [index, onPress] + [pulse] ); + const interaction = useRatingInteraction({ + allowClear, + currentTick, + defaultExtent, + disabled, + getValue, + interactionMode, + maxTick: model.maxTick, + minTick: model.minTick, + onChangeEnd, + onChangeTick, + onComplete: handleComplete, + onInteractionStart, + orientation, + positionToTick, + structure: interactionStructure, + }); + const displayTick = interaction.draftTick ?? currentTick; + const displayValue = getValue(displayTick); + const effectiveMinTick = allowClear || displayTick === 0 ? 0 : model.minTick; + const accessibilityText = formatAccessibilityValue(displayValue, model.max); return ( - - {({ pressed }) => ( - - {renderItem ? ( - renderItem({ - activeColor, - disabled, - fill, - inactiveColor, - index, - pressed, - size, - value: index + 1, - }) - ) : ( - - )} - - )} - + + + + ); }; @@ -474,50 +237,55 @@ export const Rating = ({ allowClear = false, animated = true, defaultValue = 0, + direction = "auto", disabled = false, + focusStyle, formatAccessibilityValue = defaultFormatAccessibilityValue, gap = 0, inactiveColor = DEFAULT_INACTIVE_COLOR, + interactionMode = "tap", max = DEFAULT_MAX, + min = 0, onChange, + onChangeEnd, + onInteractionStart, + orientation = "horizontal", readOnly = false, ref, renderItem, size = DEFAULT_SIZE, step = 1, - style, - testID, value, - ...viewProps + ...rootProps }: RatingProps) => { - const safeMax = normalizeMax(max); - const safeStep = normalizeStep(step); - const safeSize = normalizePositive(size, DEFAULT_SIZE); - const safeGap = Math.max(0, Number.isFinite(gap) ? gap : 0); - const targetSize = readOnly - ? safeSize - : Math.max(safeSize, PRESS_TARGET_SIZE); + const model = useMemo( + () => createNumericModel(max, min, step), + [max, min, step] + ); const controlled = value !== undefined; const [uncontrolledValue, setUncontrolledValue] = useState(() => - normalizeValue(defaultValue, safeMax, safeStep) + normalizeNumericValue(defaultValue, model) ); - const currentValue = normalizeValue( - controlled ? value : uncontrolledValue, - safeMax, - safeStep + const normalizedUncontrolledValue = normalizeNumericValue( + uncontrolledValue, + model ); - const ticksPerItem = getTicksPerItem(safeStep); - const currentTick = getClosestTick(currentValue, safeMax, safeStep); - const interactive = !disabled && !readOnly; - const reduceMotion = useReducedMotion(); - const updateValue = useCallback( - (candidate: number) => { - const nextValue = normalizeValue(candidate, safeMax, safeStep); + if (!Object.is(uncontrolledValue, normalizedUncontrolledValue)) { + setUncontrolledValue(normalizedUncontrolledValue); + } - if (nextValue === currentValue) { - return; - } + const currentTick = normalizeNumericTick( + controlled ? value : normalizedUncontrolledValue, + model + ); + const currentValue = getValueFromTick(currentTick, model); + const safeSize = normalizePositive(size, DEFAULT_SIZE); + const safeGap = normalizeGap(gap); + + const handleTickChange = useCallback( + (tick: number): void => { + const nextValue = getValueFromTick(tick, model); if (!controlled) { setUncontrolledValue(nextValue); @@ -525,89 +293,54 @@ export const Rating = ({ onChange?.(nextValue); }, - [controlled, currentValue, onChange, safeMax, safeStep] + [controlled, model, onChange] ); - const handleItemPress = useCallback( - (index: number, event: GestureResponderEvent) => { - const pressedValue = normalizeValue( - getPressedValue(index, event, safeStep, targetSize), - safeMax, - safeStep - ); - - updateValue( - allowClear && pressedValue === currentValue ? 0 : pressedValue - ); - }, - [allowClear, currentValue, safeMax, safeStep, targetSize, updateValue] - ); - - const handleAccessibilityAction = useCallback( - (event: AccessibilityActionEvent) => { - if (disabled || readOnly) { - return; - } - - if (event.nativeEvent.actionName === "increment") { - updateValue(getValueFromTick(currentTick + 1, safeMax, safeStep)); - } else if (event.nativeEvent.actionName === "decrement") { - updateValue(getValueFromTick(currentTick - 1, safeMax, safeStep)); - } - }, - [currentTick, disabled, readOnly, safeMax, safeStep, updateValue] - ); + if (readOnly) { + return ( + + ); + } return ( - - {Array.from({ length: safeMax }, (_, index) => { - const fill = clamp(currentValue - index, 0, 1); - - return ( - - ); - })} - + activeColor={activeColor} + allowClear={allowClear} + animated={animated} + currentTick={currentTick} + direction={direction} + disabled={disabled} + focusStyle={focusStyle} + formatAccessibilityValue={formatAccessibilityValue} + gap={safeGap} + inactiveColor={inactiveColor} + interactionMode={interactionMode} + model={model} + onChangeEnd={onChangeEnd} + onChangeTick={handleTickChange} + onInteractionStart={onInteractionStart} + orientation={orientation} + ref={ref} + renderItem={renderItem} + rootProps={rootProps} + size={safeSize} + /> ); }; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..9eeeca3 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,373 @@ +import type { ComponentRef, ReactNode, Ref } from "react"; +import type { + ColorValue, + StyleProp, + View, + ViewProps, + ViewStyle, +} from "react-native"; + +export type RatingDirection = "auto" | "ltr" | "rtl"; +export type ResolvedRatingDirection = Exclude; +export type RatingOrientation = "horizontal" | "vertical"; +export type RatingInteractionMode = "tap" | "tap-and-drag"; +export type RatingInteractionSource = "accessibility" | "keyboard" | "pointer"; +export type RatingFillOrigin = "bottom" | "left" | "right"; +export type RatingScaleSelectionMode = "cumulative" | "single"; +export type RatingScaleValue = number | string; + +export interface RatingInteractionDetails { + readonly source: RatingInteractionSource; +} + +export interface RatingInteractionEndDetails extends RatingInteractionDetails { + readonly cancelled: boolean; +} + +export type RatingRootProps = Omit< + ViewProps, + | "accessible" + | "accessibilityActions" + | "accessibilityElementsHidden" + | "accessibilityRole" + | "accessibilityState" + | "accessibilityValue" + | "children" + | "importantForAccessibility" + | "focusable" + | "onAccessibilityAction" + | "onMoveShouldSetResponder" + | "onMoveShouldSetResponderCapture" + | "onKeyDown" + | "onKeyDownCapture" + | "onResponderEnd" + | "onResponderGrant" + | "onResponderMove" + | "onResponderReject" + | "onResponderRelease" + | "onResponderStart" + | "onResponderTerminate" + | "onResponderTerminationRequest" + | "onShouldBlockNativeResponder" + | "onStartShouldSetResponder" + | "onStartShouldSetResponderCapture" + | "role" + | "pointerEvents" + | "tabIndex" + | "aria-disabled" + | "aria-hidden" + | "aria-label" + | "aria-orientation" + | "aria-valuemax" + | "aria-valuemin" + | "aria-valuenow" + | "aria-valuetext" +>; + +export interface RatingVisualProps extends RatingRootProps { + /** + * Color of the selected portion. + * @default "#B45309" + */ + activeColor?: ColorValue; + /** + * Color of the unselected portion. + * @default "#6B7280" + */ + inactiveColor?: ColorValue; + /** + * Resolves item layout independently of the application locale when set. + * @default "auto" + */ + direction?: RatingDirection; + /** + * Space between visual items. + * @default 0 + */ + gap?: number; + /** + * Lays items out horizontally or vertically. + * @default "horizontal" + */ + orientation?: RatingOrientation; + /** + * Size of each visible item. Interactive controls retain a 44pt/48dp + * cross-axis target without widening every item. + * @default 28 + */ + size?: number; + /** + * React 19 ref for the root native View. + */ + ref?: Ref>; +} + +export interface RatingRenderItemProps { + activeColor: ColorValue; + direction: ResolvedRatingDirection; + disabled: boolean; + fill: number; + fillOrigin: RatingFillOrigin; + inactiveColor: ColorValue; + index: number; + orientation: RatingOrientation; + pressed: boolean; + size: number; + value: number; +} + +export type RatingRenderItem = (props: RatingRenderItemProps) => ReactNode; + +export interface RatingProps extends RatingVisualProps { + /** + * Lets a parent own the current rating. Pair it with `onChange`. + */ + value?: number; + /** + * Initial value when the component is uncontrolled. + */ + defaultValue?: number; + /** + * Smallest selectable nonzero rating. Zero remains the unrated sentinel. + * @default 0 + */ + min?: number; + /** + * Number of rating items. Values outside 1–100 are clamped. + * @default 5 + */ + max?: number; + /** + * Per-item selection precision. Values outside 0.01–1 are clamped. + * @default 1 + */ + step?: number; + /** + * Called for each distinct value selected by the user. Drag interactions can + * call this more than once; keep expensive persistence in `onChangeEnd`. + */ + onChange?: (value: number) => void; + /** + * Called once when an interaction is accepted. A tap is accepted on release; + * a drag is accepted after it crosses the primary-axis movement threshold. + */ + onInteractionStart?: ( + value: number, + details: RatingInteractionDetails + ) => void; + /** + * Called once after an accepted interaction. A terminated drag reports + * `cancelled: true`. + */ + onChangeEnd?: (value: number, details: RatingInteractionEndDetails) => void; + /** + * Clears the rating on a true same-value tap or decrement at the minimum. + * @default false + */ + allowClear?: boolean; + /** + * Prevents interaction and exposes a disabled accessibility state. + * @default false + */ + disabled?: boolean; + /** + * Presents the rating through the static, allocation-light display path. + * @default false + */ + readOnly?: boolean; + /** + * Enables axis-aware drag input without requiring a gesture dependency. + * Tap remains available in both modes. + * @default "tap" + */ + interactionMode?: RatingInteractionMode; + /** + * Enables quiet completion feedback. System reduced-motion settings win. + * @default true + */ + animated?: boolean; + /** + * Overrides the default two-color Web focus indicator while focused. + */ + focusStyle?: StyleProp; + /** + * Formats the text announced with the accessibility value. + */ + formatAccessibilityValue?: (value: number, max: number) => string; + /** + * Replaces the default text star without adding an icon or SVG dependency. + * The returned subtree is visual-only; use the root accessibility props for + * semantics. + */ + renderItem?: RatingRenderItem; +} + +export interface RatingDisplayProps extends RatingVisualProps { + /** + * Value to display. It is clamped to zero through `max`. + */ + value: number; + /** + * Visually dims the display and forwards disabled state to custom items. + * @default false + */ + disabled?: boolean; + /** + * Hides the display from assistive technology when adjacent text already + * communicates the same value. + * @default false + */ + decorative?: boolean; + /** + * Number of rating items. Values outside 1–100 are clamped. + * @default 5 + */ + max?: number; + /** + * Optional display snapping. Omit it to render an exact aggregate such as + * 4.37. + */ + step?: number; + /** + * Formats the accessible value text. + */ + formatAccessibilityValue?: (value: number, max: number) => string; + /** + * Replaces the default visual item. + */ + renderItem?: RatingRenderItem; +} + +export interface RatingScaleItem< + Value extends RatingScaleValue = RatingScaleValue, +> { + /** + * Stable semantic value. Zero and negative numbers are valid. + */ + value: Value; + /** + * Human-readable meaning announced by assistive technology. + */ + label: string; + /** + * Optional default visual content, commonly an emoji or icon. + */ + content?: ReactNode; +} + +export interface RatingScaleRenderItemProps< + Value extends RatingScaleValue = RatingScaleValue, +> { + activeColor: ColorValue; + content: ReactNode | undefined; + direction: ResolvedRatingDirection; + disabled: boolean; + inactiveColor: ColorValue; + index: number; + itemExtent: number; + label: string; + orientation: RatingOrientation; + pressed: boolean; + selected: boolean; + size: number; + value: Value; +} + +export type RatingScaleRenderItem< + Value extends RatingScaleValue = RatingScaleValue, +> = (props: RatingScaleRenderItemProps) => ReactNode; + +export interface RatingScaleProps< + Value extends RatingScaleValue = RatingScaleValue, +> extends RatingVisualProps { + /** + * Ordered semantic choices. The first 100 valid items are rendered. + */ + items: readonly RatingScaleItem[]; + /** + * Controlled semantic value. `null` means no selection. + */ + value?: Value | null; + /** + * Initial uncontrolled semantic value. + */ + defaultValue?: Value | null; + /** + * Called for each distinct semantic value selected by the user. + */ + onChange?: (value: Value | null) => void; + /** + * Called once when an interaction is accepted. A tap is accepted on release; + * a drag is accepted after it crosses the primary-axis movement threshold. + */ + onInteractionStart?: ( + value: Value | null, + details: RatingInteractionDetails + ) => void; + /** + * Called once after an accepted interaction. + */ + onChangeEnd?: ( + value: Value | null, + details: RatingInteractionEndDetails + ) => void; + /** + * Allows a same-value tap or decrement at the first item to select `null`. + * @default false + */ + allowClear?: boolean; + /** + * Prevents interaction. + * @default false + */ + disabled?: boolean; + /** + * Uses a static path with no responder, animation, or motion subscription. + * @default false + */ + readOnly?: boolean; + /** + * Hides a read-only scale from assistive technology when adjacent content + * already communicates the same value. Ignored for interactive scales. + * @default false + */ + decorative?: boolean; + /** + * Enables axis-aware drag input. + * @default "tap" + */ + interactionMode?: RatingInteractionMode; + /** + * Reverses semantic item progression independently of locale direction. + * @default false + */ + reversed?: boolean; + /** + * Primary-axis length of each semantic choice. Increase it for text labels + * without inflating the cross-axis target or renderer `size`. + * @default size + */ + itemExtent?: number; + /** + * Selects only the chosen item or every item through it. + * @default "single" + */ + selectionMode?: RatingScaleSelectionMode; + /** + * Enables quiet completion feedback. + * @default true + */ + animated?: boolean; + /** + * Overrides the default two-color Web focus indicator while focused. + */ + focusStyle?: StyleProp; + /** + * Formats the announced semantic value. + */ + formatAccessibilityValue?: (item: RatingScaleItem | null) => string; + /** + * Renders a semantic visual item. + */ + renderItem?: RatingScaleRenderItem; +}