diff --git a/.gitignore b/.gitignore index 1a100cd..199db4d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ .mise.local.toml .vscode coverage +example/.expo +example/dist lib node_modules npm-debug.log diff --git a/README.md b/README.md index f98eed4..0549aeb 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,14 @@ [![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. -

Five outlined stars filling with gold one by one

+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. @@ -66,6 +66,10 @@ The example also provides `bun run ios` and `bun run android` scripts for local Tap is the conservative default. Add `interactionMode="tap-and-drag"` when scrubbing across the rating is useful: +

+ Simulator crop showing quarter step drag, tap-only, ten-point, and uncontrolled default rating inputs +

+ ```tsx + Simulator crop showing RTL hearts, custom block fill, vertical drag, and a no-animation rating +

+ ## Accessible on native and Web Interactive ratings have one focus stop, not one stop per star: @@ -125,6 +133,10 @@ Always provide a useful `accessibilityLabel`. Localize the spoken value with `fo Use `disabled` for a temporarily unavailable input. Use `RatingDisplay` or `readOnly` for content that is not an input. +

+ Simulator crop showing formatted accessible values for a percent rating and a scale formatter +

+ ## FlatList: interactive rows and exact displays ### Interactive ratings @@ -186,6 +198,10 @@ import { RatingDisplay } from "react-native-rating"; `` 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: +

+ Simulator crop showing exact, snapped, decorative, disabled, and read-only rating displays +

+ ```tsx 4.37 out of 5 @@ -195,6 +211,10 @@ import { RatingDisplay } from "react-native-rating"; `RatingScale` is generic over finite numbers or strings. `null` means “no selection,” so zero and negative values remain first-class answers. +

+ Simulator crop showing emoji, NPS, Likert, and reversed priority rating scales +

+ ### Net Promoter Score ```tsx @@ -246,6 +266,10 @@ Use `selectionMode="single"` for one highlighted choice (the default), or `selec `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. +

+ Simulator crop showing a vertical RatingScale with a selected semantic value +

+ ## 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: diff --git a/assets/rating.gif b/assets/rating.gif deleted file mode 100644 index 0b51859..0000000 Binary files a/assets/rating.gif and /dev/null differ diff --git a/assets/readme/accessibility-formatting.png b/assets/readme/accessibility-formatting.png new file mode 100644 index 0000000..7b3a181 Binary files /dev/null and b/assets/readme/accessibility-formatting.png differ diff --git a/assets/readme/custom-renderers.png b/assets/readme/custom-renderers.png new file mode 100644 index 0000000..46513bc Binary files /dev/null and b/assets/readme/custom-renderers.png differ diff --git a/assets/readme/rating-input.png b/assets/readme/rating-input.png new file mode 100644 index 0000000..50f37db Binary files /dev/null and b/assets/readme/rating-input.png differ diff --git a/assets/readme/read-only-display.png b/assets/readme/read-only-display.png new file mode 100644 index 0000000..47bdbe6 Binary files /dev/null and b/assets/readme/read-only-display.png differ diff --git a/assets/readme/scale-states.png b/assets/readme/scale-states.png new file mode 100644 index 0000000..0b7a4e7 Binary files /dev/null and b/assets/readme/scale-states.png differ diff --git a/assets/readme/semantic-scales.png b/assets/readme/semantic-scales.png new file mode 100644 index 0000000..eb19abb Binary files /dev/null and b/assets/readme/semantic-scales.png differ diff --git a/example/app.json b/example/app.json index 80f5788..d3be254 100644 --- a/example/app.json +++ b/example/app.json @@ -4,7 +4,7 @@ "slug": "react-native-rating-example", "version": "1.0.0", "orientation": "portrait", - "userInterfaceStyle": "light", + "userInterfaceStyle": "dark", "newArchEnabled": true, "web": { "bundler": "metro", diff --git a/example/app.tsx b/example/app.tsx index 62b16d3..88ba58d 100644 --- a/example/app.tsx +++ b/example/app.tsx @@ -1,10 +1,23 @@ -import { useCallback, useState } from "react"; -import { Platform, ScrollView, StyleSheet, Text, View } from "react-native"; +import { useCallback, useMemo, useState } from "react"; +import type { ReactNode } from "react"; +import { + Platform, + ScrollView, + StyleSheet, + StatusBar, + Text, + useWindowDimensions, + View, +} from "react-native"; import { Rating, RatingDisplay, RatingScale } from "react-native-rating"; import type { + RatingInteractionDetails, + RatingInteractionEndDetails, RatingRenderItemProps, RatingScaleItem, + RatingScaleRenderItemProps, } from "react-native-rating"; +import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context"; const EXPERIENCE_ITEMS = [ { @@ -24,106 +37,82 @@ const EXPERIENCE_ITEMS = [ }, ] as const satisfies readonly RatingScaleItem[]; +const NPS_ITEMS = Array.from({ length: 11 }, (_, score) => ({ + content: score, + label: `${score} out of 10`, + value: score, +})) satisfies RatingScaleItem[]; + +const LIKERT_ITEMS = [ + { content: "Strong no", label: "Strongly disagree", value: "strong-no" }, + { content: "No", label: "Disagree", value: "no" }, + { content: "Mixed", label: "Neither agree nor disagree", value: "mixed" }, + { content: "Yes", label: "Agree", value: "yes" }, + { content: "Strong yes", label: "Strongly agree", value: "strong-yes" }, +] as const satisfies readonly RatingScaleItem[]; + +const COMPACT_LIKERT_ITEMS = [ + { content: "S no", label: "Strongly disagree", value: "strong-no" }, + { content: "No", label: "Disagree", value: "no" }, + { content: "Mixed", label: "Neither agree nor disagree", value: "mixed" }, + { content: "Yes", label: "Agree", value: "yes" }, + { content: "S yes", label: "Strongly agree", value: "strong-yes" }, +] as const satisfies readonly RatingScaleItem[]; + +const PRIORITY_ITEMS = [ + { content: "P1", label: "Low priority", value: "low" }, + { content: "P2", label: "Medium priority", value: "medium" }, + { content: "P3", label: "High priority", value: "high" }, + { content: "P4", label: "Critical priority", value: "critical" }, +] as const satisfies readonly RatingScaleItem[]; + +const CONFIDENCE_ITEMS = [ + { content: "Guess", label: "Guess", value: "guess" }, + { content: "Some", label: "Some evidence", value: "some" }, + { content: "Good", label: "Good evidence", value: "good" }, + { content: "Certain", label: "Certain", value: "certain" }, +] as const satisfies readonly RatingScaleItem[]; + +const formatRating = (value: number, max: number): string => + `${value.toFixed(2)} of ${max}`; + +const formatPercent = (value: number, max: number): string => + `${Math.round((value / max) * 100)} percent filled`; + const styles = StyleSheet.create({ app: { - backgroundColor: "#FAF7F2", + backgroundColor: "#06080F", flex: 1, }, card: { - backgroundColor: "#FFFFFF", - borderColor: "#E7E0D8", - borderRadius: 20, + backgroundColor: "#10141D", + borderColor: "#273244", + borderRadius: 8, borderWidth: 1, - gap: 14, - padding: 20, + gap: 16, + padding: 16, ...Platform.select({ web: { - boxShadow: "0 12px 32px rgba(64, 45, 28, 0.08)", + boxShadow: "0 18px 45px rgba(0, 0, 0, 0.34)", }, }), }, + cell: { + flexBasis: 260, + flexGrow: 1, + flexShrink: 1, + gap: 8, + minWidth: 0, + }, content: { alignSelf: "center", - gap: 20, - maxWidth: 720, - paddingBottom: 64, - paddingHorizontal: 20, - paddingTop: 48, + gap: 16, + maxWidth: 920, + paddingBottom: 56, + paddingHorizontal: 16, + paddingTop: 12, 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", @@ -151,11 +140,78 @@ const styles = StyleSheet.create({ customTop: { top: 0, }, + eyebrow: { + color: "#5EEAD4", + fontSize: 12, + fontWeight: "800", + letterSpacing: 0.8, + lineHeight: 16, + textTransform: "uppercase", + }, + featureGrid: { + flexDirection: "row", + flexWrap: "wrap", + gap: 12, + }, + footerStatus: { + backgroundColor: "#F4C95D", + borderRadius: 8, + color: "#17120B", + fontSize: 13, + fontVariant: ["tabular-nums"], + fontWeight: "700", + lineHeight: 18, + overflow: "hidden", + paddingHorizontal: 14, + paddingVertical: 12, + }, + hint: { + color: "#8FA0B8", + fontSize: 13, + lineHeight: 18, + }, + inlineRow: { + alignItems: "center", + flexDirection: "row", + flexWrap: "wrap", + gap: 10, + }, + metric: { + color: "#F8FAFC", + fontSize: 16, + fontVariant: ["tabular-nums"], + fontWeight: "800", + lineHeight: 21, + }, + scaleBox: { + alignItems: "center", + borderRadius: 7, + borderWidth: 1, + justifyContent: "center", + }, + scaleBoxLabel: { + fontSize: 11, + fontWeight: "800", + lineHeight: 13, + textAlign: "center", + }, + sectionTitle: { + color: "#F8FAFC", + fontSize: 18, + fontWeight: "800", + lineHeight: 23, + }, + splitRow: { + flexDirection: "row", + flexWrap: "wrap", + gap: 14, + }, + scroll: { + backgroundColor: "#06080F", + flex: 1, + }, }); -const formatScore = (value: number): string => - `${value.toFixed(2)} out of 5 stars`; - const renderHeart = ({ activeColor, fill, @@ -211,155 +267,650 @@ const renderHeart = ({ ); }; +const renderBlock = ({ + activeColor, + fill, + fillOrigin, + inactiveColor, + size, +}: RatingRenderItemProps) => { + const fromEnd = fillOrigin === "right"; + const fromBottom = fillOrigin === "bottom"; + + return ( + + + + ); +}; + +const renderScaleBox = ({ + activeColor, + content, + inactiveColor, + itemExtent, + label, + orientation, + selected, + size, +}: RatingScaleRenderItemProps) => ( + + + {content ?? label} + + +); + +const Section = ({ + children, + hint, + title, +}: { + children: ReactNode; + hint?: string; + title: string; +}) => ( + + + {title} + + {children} + {hint ? {hint} : null} + +); + +const FeatureCell = ({ + children, + label, + value, +}: { + children: ReactNode; + label: string; + value?: string; +}) => ( + + {label} + {children} + {value ? {value} : null} + +); + +const describeStart = ( + label: string, + value: number | string | null, + details: RatingInteractionDetails +): string => `${label} start: ${value ?? "empty"} by ${details.source}`; + +const describeEnd = ( + label: string, + value: number | string | null, + details: RatingInteractionEndDetails +): string => + `${label} end: ${value ?? "empty"} by ${details.source}${ + details.cancelled ? " (cancelled)" : "" + }`; + export function App() { - const [score, setScore] = useState(3.75); + const { width } = useWindowDimensions(); + const [preciseScore, setPreciseScore] = useState(3.75); + const [tapScore, setTapScore] = useState(4); + const [tenPointScore, setTenPointScore] = useState(7); const [heartScore, setHeartScore] = useState(3.5); - const [intensity, setIntensity] = useState(3); + const [blockScore, setBlockScore] = useState(2.6); + const [verticalScore, setVerticalScore] = useState(3); + const [calmScore, setCalmScore] = useState(4); const [experience, setExperience] = useState(0); - const [status, setStatus] = useState( - "Try touch, mouse, keyboard, or a screen reader." + const [npsScore, setNpsScore] = useState(8); + const [likert, setLikert] = useState("yes"); + const [priority, setPriority] = useState("medium"); + const [confidence, setConfidence] = useState("good"); + const [status, setStatus] = useState("Ready for capture."); + const compactScales = width < 520; + const likertItems = compactScales ? COMPACT_LIKERT_ITEMS : LIKERT_ITEMS; + + const focusStyle = useMemo( + () => ({ + borderColor: "#5EEAD4", + borderRadius: 8, + borderWidth: 2, + }), + [] ); - 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}”.`); - }, []); + const setNumericStatus = useCallback( + (label: string) => + (value: number, details: RatingInteractionEndDetails): void => { + setStatus(describeEnd(label, value, details)); + }, + [] + ); + const setScaleStatus = useCallback( + (label: string) => + ( + value: number | string | null, + details: RatingInteractionEndDetails + ): void => { + setStatus(describeEnd(label, value, details)); + }, + [] + ); 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. - - + + + + +
+ + + { + setStatus(describeStart("Quarter step", value, details)); + }} + size={40} + step={0.25} + value={preciseScore} + /> + - - - 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} - - + + + + + + + + + + + + +
+ +
+ + + + + 4.37 + + + + + + + + + + 4.80 of 5 + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + Priority: high + + + + + + + + +
+ +
+ + + + + + + + item ? `Selected ${item.label}` : "Nothing selected yet" + } + gap={compactScales ? 2 : 3} + itemExtent={compactScales ? 28 : 34} + inactiveColor="#64748B" + items={NPS_ITEMS} + readOnly + renderItem={renderScaleBox} + selectionMode="cumulative" + size={30} + value={6} + /> + + +
+ + + {status} + +
+
+
); } diff --git a/example/metro.config.js b/example/metro.config.js new file mode 100644 index 0000000..fb468e5 --- /dev/null +++ b/example/metro.config.js @@ -0,0 +1,54 @@ +const path = require("node:path"); +const { getDefaultConfig } = require("expo/metro-config"); +const exclusionList = + require("metro-config/private/defaults/exclusionList").default; + +const projectRoot = __dirname; +const workspaceRoot = path.resolve(projectRoot, ".."); +const installedPackageRoot = path.join( + projectRoot, + "node_modules/react-native-rating" +); + +const config = getDefaultConfig(projectRoot); +const escapePath = (value) => + value.replaceAll("\\", "\\\\").replace(/[|\\{}()[\]^$+*?.]/gu, "\\$&"); + +config.watchFolders = Array.from( + new Set([...(config.watchFolders ?? []), workspaceRoot]) +); + +config.resolver = { + ...config.resolver, + blockList: exclusionList([ + new RegExp( + `${escapePath(path.join(installedPackageRoot, "node_modules/react"))}(/.*)?$` + ), + new RegExp( + `${escapePath(path.join(installedPackageRoot, "node_modules/react-native"))}(/.*)?$` + ), + new RegExp( + `${escapePath(path.join(installedPackageRoot, "example/node_modules/react"))}(/.*)?$` + ), + ]), + disableHierarchicalLookup: true, + extraNodeModules: { + ...(config.resolver.extraNodeModules ?? {}), + react: path.join(projectRoot, "node_modules/react"), + "react/jsx-dev-runtime": path.join( + projectRoot, + "node_modules/react/jsx-dev-runtime" + ), + "react/jsx-runtime": path.join( + projectRoot, + "node_modules/react/jsx-runtime" + ), + "react-native": path.join(projectRoot, "node_modules/react-native"), + }, + nodeModulesPaths: [ + path.join(projectRoot, "node_modules"), + path.join(workspaceRoot, "node_modules"), + ], +}; + +module.exports = config; diff --git a/example/package.json b/example/package.json index bd7e329..64ee36d 100644 --- a/example/package.json +++ b/example/package.json @@ -18,6 +18,7 @@ "react-dom": "19.2.8", "react-native": "0.86.2", "react-native-rating": "file:..", + "react-native-safe-area-context": "~5.7.0", "react-native-web": "0.21.2" }, "devDependencies": {