@delacour/react-native-ui 0.1.0-alpha.20260925063207 → 0.1.0-alpha.20260925063807

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delacour/react-native-ui",
3
- "version": "0.1.0-alpha.20260925063207",
3
+ "version": "0.1.0-alpha.20260925063807",
4
4
  "description": "React Native UI components — Uniwind, Reanimated, Gesture Handler",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -53,11 +53,13 @@
53
53
  "./progress": "./src/components/progress/index.ts",
54
54
  "./provider": "./src/components/provider/index.ts",
55
55
  "./radio": "./src/components/radio/index.ts",
56
+ "./rating": "./src/components/rating/index.ts",
56
57
  "./screen": "./src/components/screen/index.ts",
57
58
  "./separator": "./src/components/separator/index.ts",
58
59
  "./skeleton": "./src/components/skeleton/index.ts",
59
60
  "./slider": "./src/components/slider/index.ts",
60
61
  "./spinner": "./src/components/spinner/index.ts",
62
+ "./steps": "./src/components/steps/index.ts",
61
63
  "./surface": "./src/components/surface/index.ts",
62
64
  "./switch": "./src/components/switch/index.ts",
63
65
  "./tabs": "./src/components/tabs/index.ts",
@@ -92,7 +94,7 @@
92
94
  },
93
95
  "peerDependencies": {
94
96
  "@central-icons-react-native/round-outlined-radius-1-stroke-1.5": "^1.1",
95
- "@delacour/react-native-charts": "0.1.0-alpha.20260925063207",
97
+ "@delacour/react-native-charts": "0.1.0-alpha.20260925063807",
96
98
  "@gorhom/bottom-sheet": "^5.2.8",
97
99
  "@legendapp/list": ">=3.3",
98
100
  "expo-linear-gradient": ">=15",
@@ -0,0 +1,143 @@
1
+ # Rating
2
+
3
+ A row of stars that reads or sets a score. Compound root plus `Rating.Stars` and
4
+ `Rating.Output`. Read-only or interactive, in whole or fractional steps, set by a
5
+ tap or a drag along the row.
6
+
7
+ `import { Rating } from "@delacour/react-native-ui/rating";`
8
+
9
+ ## Files
10
+
11
+ | File | What it holds |
12
+ | --- | --- |
13
+ | `index.ts` | → `@delacour/react-native-ui/rating` |
14
+ | `rating.tsx` | Root + the `Object.assign` compound surface |
15
+ | `rating-stars.tsx` | `Rating.Stars` — owns the pan, the measurement, the haptic and the accessibility surface |
16
+ | `rating-star.tsx` | One star: an empty glyph and a clipped filled one. Internal |
17
+ | `rating-output.tsx` | `Rating.Output`, the `Text.Label` readout |
18
+ | `rating.context.tsx` | `RatingContext`, `useRating()`, `useRatingContext()`, `useRatingPart()`, `RatingRenderProps` |
19
+ | `rating.variants.ts` | Pure `tv()` slots, the value maths and the paint resolver, no RN imports |
20
+ | `rating.variants.test.ts` | |
21
+
22
+ There is no `rating.types.ts`: `RatingRenderProps` is the one shared type and it
23
+ is part of the context's own shape, so it lives beside it.
24
+
25
+ ## Design
26
+
27
+ - **Colours**: `default`, `primary`, `success`, `warning`, `destructive`, `info` —
28
+ Slider's set. The default is **`warning`**, not `default`: a star is read as
29
+ amber before it is read as a star, and a row of ink stars reads as a set of
30
+ icons rather than a score. **Sizes**: `sm`, `md`, `lg`, which are `icon-lg`,
31
+ `icon-xl` and `icon-2xl` on the icon scale — a star is a mark, the way a
32
+ checkbox's tick is.
33
+ - **The anatomy is written out**, `Rating.Stars` and an optional
34
+ `Rating.Output`, rather than a `showValue` boolean. A readout beside the stars,
35
+ under them, or formatted as "4.3 · 128 reviews" is three layouts one boolean
36
+ could not express; the root is a column and a `flex-row` class puts it inline.
37
+ - **`maxValue` is the star count and `step` the precision**, the names `Slider`
38
+ uses for the same two ideas. A fraction of a count rounds *down*; anything
39
+ that is not a count falls back to five. A step must divide one star — `0.5`
40
+ and `0.25` do, `0.3` does not — and falls back to whole stars otherwise,
41
+ because a rating is usually fed a number from a server rather than a literal.
42
+
43
+ ## Value
44
+
45
+ - **Any value is drawn; only a touch is snapped.** `clampRating` holds the value
46
+ inside zero and the count and does not snap, so a read-only average of 3.7
47
+ fills seven tenths of the fourth star. `step` constrains what a finger can set.
48
+ A non-finite value draws as zero — a star whose width came out of `NaN` is a
49
+ row that lies about its score.
50
+ - **A touch rounds up to the next stop.** Any touch inside a star's cell takes
51
+ that star, and with half stars its left half is the half. A touch exactly on a
52
+ cell's edge belongs to the star before it; the epsilon in `ratingFromOffset` is
53
+ what stops `Math.ceil` and binary floating point disagreeing there.
54
+ - **A drag past the first star holds the lowest stop; it does not clear.** A drag
55
+ that overshoots the start is someone rating one star, not someone withdrawing
56
+ a rating. Clearing is its own gesture.
57
+ - **`allowClear` is a tap on the value already held**, decided in `onFinalize` by
58
+ `shouldClearRating`. It reads the travel since touch-down, so a drag that
59
+ wanders off and comes back to the same star is a drag rather than a clear, and
60
+ `RATING_TAP_SLOP` is the line between them.
61
+ - **The fills sum back to the value.** `starFillOf` gives every star before the
62
+ value a full fill, every star after it none and the one it lands in the
63
+ remainder, and a test sums them.
64
+
65
+ ## Drawing
66
+
67
+ - **The star is an SVG path drawn here, not an icon.** Central Icons is an
68
+ outlined set: a filled star, and the clipped half of one, has no glyph there.
69
+ The path is ten vertices, stroked in its own fill colour with round joins, which
70
+ softens the points to sit beside the rounded icon set.
71
+ - **A partial star is two layers and a clip, not a gradient.** The empty glyph
72
+ sits underneath and the filled one is inside a `View` whose width is the fill as
73
+ a percentage, with `overflow-hidden`. The filled glyph keeps the full glyph's
74
+ size — the window narrows, never the star. A hard-stop gradient would draw the
75
+ same thing, but its stops are paint props re-parsed on every change.
76
+ - **The glyph's size is a class on a `View`, and the `Svg` fills it at `100%`.**
77
+ That keeps the icon scale the one source of the size without wrapping `Svg` in
78
+ `withUniwind`, which rule 7 forbids.
79
+ - **The colour is a token read with `useThemeColor`, not a class.** A path's
80
+ `fill` is a paint prop no `className` reaches. `RATING_FILL_TOKEN` names the
81
+ token per colour and `resolveRatingPaint` settles it, so the whole paint matrix
82
+ is reachable from `bun test`, which also checks every token it names exists in
83
+ both variants of `theme.css`.
84
+ - **An empty star is `muted-foreground` at 40% — the same chrome at every colour.**
85
+ No token sits where an empty star needs to: `border` and `input` vanish against
86
+ a light card, and `muted-foreground` at full strength reads as a disabled
87
+ filled star. The opacity is on the whole `Svg` view, not on the path's
88
+ `fillOpacity` and `strokeOpacity` — half the stroke overlaps the fill, and two
89
+ translucent paints draw a darker ring round every empty star.
90
+ - **Invalid outranks the colour on the empty stars too.** A required rating left
91
+ at zero has no filled star to turn red, so the outlines carry the signal or
92
+ there is none.
93
+
94
+ ## Gesture
95
+
96
+ - **Neither the row nor a star is a `Pressable`**, for the reasons
97
+ [Slider](../slider/AGENTS.md) gives for its track: a tap-to-set would fire
98
+ `onPress` every time, and a `Pressable` per star would nest five taps inside the
99
+ row's pan. One `Gesture.Pan()` on the row reads the star under the finger. What
100
+ is inherited is `playHaptic`, the one haptic switch.
101
+ - **The value is written in `onBegin`**, because a pan activates on the first
102
+ movement and a stationary tap would never reach `onUpdate`. `minDistance(0)`
103
+ wins the touch from an enclosing scroll view and `shouldCancelWhenOutside(false)`
104
+ keeps a drag past the last star tracking — both exactly as `Slider.Track`.
105
+ - **Each star's cell carries the gap as padding**, so every cell is the same width
106
+ and a star's half is the half of its cell. A `gap-*` on the row would leave dead
107
+ space between cells that belongs to no star.
108
+ - **The row is padded on the cross axis only**, to 44pt at every size, and a test
109
+ asserts the sum. The main axis is not padded: the value is read straight off
110
+ the touch's offset along the row, and a gutter there would shift every value.
111
+ - **The stars render from React state; the gesture reads a shared value.** Unlike
112
+ a slider, a rating crosses a handful of stops in a whole drag, so a commit per
113
+ crossing is a handful of commits and there is no UI-thread mirror to pay for.
114
+ The shared value exists so the worklet can tell a crossing from a repeat
115
+ without waiting on a render. It is re-synced from React on every gesture's end,
116
+ through a token that exists only to re-run the effect — without it a controlled
117
+ parent that rejects a value leaves the shared value holding it.
118
+ - **The haptic ticks on grab, on every star crossed and on a clear.** There is no
119
+ distance rate limit as there is on `Slider`: the stops are a star apart, so a
120
+ flick across ten of them is ten ticks a hand can feel.
121
+
122
+ ## Accessibility
123
+
124
+ - **The row is one `adjustable` control, not five buttons.** Its value is spoken
125
+ as "3.5 out of 5" and a swipe up or down calls `stepRating`, which lands a value
126
+ between stops on the *next* stop in the direction asked rather than a whole
127
+ step past it. Five buttons would make a screen-reader user visit every star to
128
+ learn one number.
129
+ - **A read-only rating is an `image` with the same value** — something to read,
130
+ not something to set, and no actions.
131
+ - **`Rating.Output` is hidden from assistive technology**, because the row already
132
+ speaks the value and a second, terser reading one swipe later is noise.
133
+ - **Pass `accessibilityLabel` to `Rating.Stars`**, not the root. It defaults to
134
+ "Rating", which is correct and says nothing about *what* is being rated.
135
+
136
+ ## Axes
137
+
138
+ - **The ladder is two rungs**, the rating's own props then an enclosing
139
+ [Field](../field/AGENTS.md), as on `Slider`. A `Field` reaches the two *state*
140
+ axes only, and a test pins that it cannot acquire a paint axis.
141
+ - **`isReadOnly` is the rating's own and does not fade.** A score on a review card
142
+ is content, not a control that happens to be off, so it takes no touch and no
143
+ assistive actions but keeps full strength. `isDisabled` is the one that fades.
@@ -0,0 +1,43 @@
1
+ export { Rating, type RatingProps } from "./rating";
2
+ export {
3
+ type RatingContextValue,
4
+ RatingProvider,
5
+ type RatingRenderProps,
6
+ useRating,
7
+ useRatingContext,
8
+ } from "./rating.context";
9
+ export {
10
+ clampRating,
11
+ describeRating,
12
+ formatRatingValue,
13
+ normalizeRatingCount,
14
+ normalizeRatingStep,
15
+ RATING_COLORS,
16
+ RATING_COUNT,
17
+ RATING_DEFAULT_COLOR,
18
+ RATING_DEFAULT_SIZE,
19
+ RATING_EMPTY_OPACITY,
20
+ RATING_FILL_TOKEN,
21
+ RATING_OUTPUT_TEXT_SIZE,
22
+ RATING_SIZES,
23
+ RATING_STAR_PATH,
24
+ RATING_STAR_STROKE,
25
+ RATING_STEP,
26
+ RATING_TAP_SLOP,
27
+ type RatingAxes,
28
+ type RatingColor,
29
+ type RatingFieldAxes,
30
+ type RatingOwnAxes,
31
+ type RatingPaint,
32
+ type RatingSize,
33
+ type RatingVariantProps,
34
+ ratingFromOffset,
35
+ ratingVariants,
36
+ resolveRatingAxes,
37
+ resolveRatingPaint,
38
+ shouldClearRating,
39
+ starFillOf,
40
+ stepRating,
41
+ } from "./rating.variants";
42
+ export type { RatingOutputProps } from "./rating-output";
43
+ export type { RatingStarsProps } from "./rating-stars";
@@ -0,0 +1,39 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ import { Text, type TextPresetProps } from "../text";
3
+ import { type RatingRenderProps, useRatingPart } from "./rating.context";
4
+ import { formatRatingValue, RATING_OUTPUT_TEXT_SIZE, ratingVariants } from "./rating.variants";
5
+
6
+ export type RatingOutputProps = Omit<TextPresetProps, "children"> & {
7
+ /** Custom content, or a function called with the rating's settled state. */
8
+ children?: ReactNode | ((props: RatingRenderProps) => ReactNode);
9
+ };
10
+
11
+ /**
12
+ * The value, formatted — `4`, or `3.5`.
13
+ *
14
+ * Renders `Text.Label` and names a size step, never a scale of its own, the rule
15
+ * `Slider.Output` follows: the preset owns the weight and the colour, and the
16
+ * readout still tracks the rating's size.
17
+ *
18
+ * It is hidden from assistive technology. `Rating.Stars` already speaks the
19
+ * value as "3.5 out of 5", and a second, terser reading of the same number one
20
+ * swipe later is noise.
21
+ */
22
+ export function RatingOutput({ children, className, size, ...props }: RatingOutputProps): ReactElement {
23
+ const context = useRatingPart("Rating.Output");
24
+ const content =
25
+ typeof children === "function" ? children(context.renderProps) : (children ?? formatRatingValue(context.value));
26
+
27
+ return (
28
+ <Text.Label
29
+ accessibilityElementsHidden
30
+ className={ratingVariants({ size: context.size }).output({ className })}
31
+ importantForAccessibility="no-hide-descendants"
32
+ size={size ?? RATING_OUTPUT_TEXT_SIZE[context.size]}
33
+ {...props}
34
+ >
35
+ {content}
36
+ </Text.Label>
37
+ );
38
+ }
39
+ RatingOutput.displayName = "DelacourUI.Rating.Output";
@@ -0,0 +1,70 @@
1
+ import type { ReactElement } from "react";
2
+ import { View } from "react-native";
3
+ import Svg, { Path } from "react-native-svg";
4
+ import { RATING_EMPTY_OPACITY, RATING_STAR_PATH, RATING_STAR_STROKE } from "./rating.variants";
5
+
6
+ export type RatingStarProps = {
7
+ /** How much of the star is filled, 0–1. */
8
+ fill: number;
9
+ /** The resolved colour of the filled part. */
10
+ fillColor: string | undefined;
11
+ /** The resolved colour of the empty star, drawn at {@link RATING_EMPTY_OPACITY}. */
12
+ emptyColor: string | undefined;
13
+ /** The `size-icon-*` box the glyph fills. */
14
+ glyphClassName: string;
15
+ /** The clip window's class. Its width is set here, from `fill`. */
16
+ clipClassName: string;
17
+ };
18
+
19
+ /**
20
+ * One star: an empty glyph, and a filled one clipped to `fill` of its width.
21
+ *
22
+ * **Two layers rather than a gradient.** A hard-stop linear gradient on one path
23
+ * would draw the half star too, but its stops are paint props that React Native
24
+ * SVG re-parses on every change, and a clip is a `View` width the layout engine
25
+ * already animates cheaply. The filled glyph inside the clip keeps the full
26
+ * glyph's size — it is the *window* that narrows, never the star.
27
+ *
28
+ * Both glyphs are sized by a `size-icon-*` class on a `View` and fill it at
29
+ * `100%`, which keeps the icon scale the single source of the size without
30
+ * wrapping `Svg` in `withUniwind` (rule 7).
31
+ *
32
+ * Internal: `Rating.Stars` renders one per count, and nothing outside reaches it.
33
+ */
34
+ export function RatingStar({
35
+ fill,
36
+ fillColor,
37
+ emptyColor,
38
+ glyphClassName,
39
+ clipClassName,
40
+ }: RatingStarProps): ReactElement {
41
+ return (
42
+ <View className={glyphClassName}>
43
+ <StarGlyph color={emptyColor} opacity={RATING_EMPTY_OPACITY} />
44
+ {fill > 0 ? (
45
+ <View className={clipClassName} style={{ width: `${fill * 100}%` }}>
46
+ <View className={glyphClassName}>
47
+ <StarGlyph color={fillColor} opacity={1} />
48
+ </View>
49
+ </View>
50
+ ) : null}
51
+ </View>
52
+ );
53
+ }
54
+ RatingStar.displayName = "DelacourUI.Rating.Stars.Star";
55
+
56
+ /**
57
+ * The path itself, filled and stroked in one colour so the points come out rounded.
58
+ *
59
+ * The opacity is the whole view's, not the path's `fillOpacity` and
60
+ * `strokeOpacity`: half the stroke overlaps the fill, and two translucent paints
61
+ * over one another draw a darker ring round every empty star.
62
+ */
63
+ function StarGlyph({ color, opacity }: { color: string | undefined; opacity: number }): ReactElement {
64
+ return (
65
+ <Svg height="100%" style={{ opacity }} viewBox="0 0 24 24" width="100%">
66
+ <Path d={RATING_STAR_PATH} fill={color} stroke={color} strokeLinejoin="round" strokeWidth={RATING_STAR_STROKE} />
67
+ </Svg>
68
+ );
69
+ }
70
+ StarGlyph.displayName = "DelacourUI.Rating.Stars.Star.Glyph";
@@ -0,0 +1,219 @@
1
+ import { type ReactElement, useCallback, useEffect, useMemo, useState } from "react";
2
+ import { type AccessibilityActionEvent, type LayoutChangeEvent, View, type ViewProps } from "react-native";
3
+ import { Gesture, GestureDetector } from "react-native-gesture-handler";
4
+ import { useSharedValue } from "react-native-reanimated";
5
+ import { scheduleOnRN } from "react-native-worklets";
6
+ import { useThemeColor } from "../../hooks/use-theme-color";
7
+ import { playHaptic } from "../pressable";
8
+ import { useRatingPart } from "./rating.context";
9
+ import {
10
+ describeRating,
11
+ ratingFromOffset,
12
+ ratingVariants,
13
+ resolveRatingPaint,
14
+ shouldClearRating,
15
+ starFillOf,
16
+ stepRating,
17
+ } from "./rating.variants";
18
+ import { RatingStar } from "./rating-star";
19
+
20
+ export type RatingStarsProps = Omit<ViewProps, "children" | "style"> & {
21
+ className?: string;
22
+ };
23
+
24
+ /**
25
+ * The row of stars, the surface the gesture is claimed on, and the whole
26
+ * accessibility surface.
27
+ *
28
+ * **It is not a `Pressable`, and neither is a star.** The reasons are
29
+ * `Slider.Track`'s: `Pressable`'s tap fires `onPress` on every touch-to-set, and a
30
+ * `Pressable` per star would nest five taps inside the row's pan and leave them
31
+ * to negotiate for one drag. One `Gesture.Pan()` on the row reads the star under
32
+ * the finger, on touch-down and throughout the drag. What is inherited is the
33
+ * vocabulary — `playHaptic` from `pressable.tsx`, the one haptic switch.
34
+ *
35
+ * **The value is written in `onBegin`**, because a pan activates on the first
36
+ * movement and a stationary tap would otherwise never set anything.
37
+ * `minDistance(0)` wins the touch from an enclosing scroll view, and
38
+ * `shouldCancelWhenOutside(false)` keeps a drag past the last star tracking it.
39
+ * `onFinalize` is where a clear is decided and the gesture reported finished,
40
+ * because it is the one callback that fires on every path.
41
+ *
42
+ * **The stars render from React state; the gesture reads a shared value.** A
43
+ * rating crosses at most a handful of stops in a drag, so a commit per crossing
44
+ * is a handful of commits and no mirror on the UI thread is worth its code. The
45
+ * shared value exists so the worklet can tell a crossing from a repeat without
46
+ * waiting on a render, and it is re-synced from React whenever the gesture ends —
47
+ * which is what snaps a controlled rating back when its parent rejects a value.
48
+ *
49
+ * **To assistive technology the row is one `adjustable` control.** Its value is
50
+ * spoken as "3.5 out of 5" and a swipe up or down steps it. A read-only rating
51
+ * is an `image` with the same value: something to read, not something to set.
52
+ */
53
+ export function RatingStars({ className, onLayout, ...props }: RatingStarsProps): ReactElement {
54
+ const {
55
+ value,
56
+ count,
57
+ step,
58
+ color,
59
+ size,
60
+ isDisabled,
61
+ isInvalid,
62
+ isReadOnly,
63
+ allowClear,
64
+ haptic,
65
+ setValue,
66
+ commitEnd,
67
+ } = useRatingPart("Rating.Stars");
68
+ const slots = ratingVariants({ isDisabled, size });
69
+ const paint = resolveRatingPaint({ color, isInvalid });
70
+ const fillColor = useThemeColor(paint.fill);
71
+ const emptyColor = useThemeColor(paint.empty);
72
+ const isInteractive = !isDisabled && !isReadOnly;
73
+
74
+ const width = useSharedValue(0);
75
+ const current = useSharedValue(value);
76
+ const startValue = useSharedValue(value);
77
+ const startX = useSharedValue(0);
78
+ const travel = useSharedValue(0);
79
+
80
+ // Bumped when a gesture ends, purely to re-run the sync below. A controlled
81
+ // parent that rejects a value leaves `value` unchanged, so without the token
82
+ // the shared value would keep the rejected one and the next tap would compare
83
+ // against a number nobody holds.
84
+ const [settledGestures, setSettledGestures] = useState(0);
85
+ const settle = useCallback(() => setSettledGestures((settledCount) => settledCount + 1), []);
86
+
87
+ // biome-ignore lint/correctness/useExhaustiveDependencies: the token is the re-run trigger, see above
88
+ useEffect(() => {
89
+ current.value = value;
90
+ }, [current, value, settledGestures]);
91
+
92
+ const handleLayout = useCallback(
93
+ (event: LayoutChangeEvent) => {
94
+ width.value = event.nativeEvent.layout.width;
95
+ onLayout?.(event);
96
+ },
97
+ [onLayout, width]
98
+ );
99
+
100
+ const gesture = useMemo(() => {
101
+ // Beside its callers rather than at module scope, so it is captured by
102
+ // ordinary closure — see Slider's AGENTS.md on module-scope worklets.
103
+ const applyTouch = (x: number, isGrab: boolean) => {
104
+ "worklet";
105
+ const next = ratingFromOffset({ count, position: x, step, width: width.value });
106
+ if (next <= 0) return;
107
+
108
+ const changed = next !== current.value;
109
+ if (changed) {
110
+ current.value = next;
111
+ scheduleOnRN(setValue, next);
112
+ }
113
+ if (haptic !== false && (isGrab || changed)) playHaptic(haptic);
114
+ };
115
+
116
+ return Gesture.Pan()
117
+ .enabled(isInteractive)
118
+ .minDistance(0)
119
+ .shouldCancelWhenOutside(false)
120
+ .onBegin((event) => {
121
+ "worklet";
122
+ startValue.value = current.value;
123
+ startX.value = event.x;
124
+ travel.value = 0;
125
+ applyTouch(event.x, true);
126
+ })
127
+ .onUpdate((event) => {
128
+ "worklet";
129
+ const moved = Math.abs(event.x - startX.value);
130
+ if (moved > travel.value) travel.value = moved;
131
+ applyTouch(event.x, false);
132
+ })
133
+ .onFinalize(() => {
134
+ "worklet";
135
+ if (
136
+ shouldClearRating({
137
+ allowClear,
138
+ startValue: startValue.value,
139
+ touchedValue: current.value,
140
+ travel: travel.value,
141
+ })
142
+ ) {
143
+ current.value = 0;
144
+ scheduleOnRN(setValue, 0);
145
+ if (haptic !== false) playHaptic(haptic);
146
+ }
147
+ scheduleOnRN(commitEnd, current.value);
148
+ scheduleOnRN(settle);
149
+ });
150
+ }, [
151
+ allowClear,
152
+ commitEnd,
153
+ count,
154
+ current,
155
+ haptic,
156
+ isInteractive,
157
+ setValue,
158
+ settle,
159
+ startValue,
160
+ startX,
161
+ step,
162
+ travel,
163
+ width,
164
+ ]);
165
+
166
+ const handleAccessibilityAction = useCallback(
167
+ (event: AccessibilityActionEvent) => {
168
+ if (!isInteractive) return;
169
+ const direction = event.nativeEvent.actionName === "increment" ? 1 : -1;
170
+ const next = stepRating(value, direction, step, count);
171
+ if (next === value) return;
172
+ setValue(next);
173
+ commitEnd(next);
174
+ },
175
+ [commitEnd, count, isInteractive, setValue, step, value]
176
+ );
177
+
178
+ const glyphClassName = slots.glyph();
179
+ const clipClassName = slots.clip();
180
+ const cellClassName = slots.cell();
181
+ const stars = Array.from({ length: count }, (_, index) => index);
182
+
183
+ return (
184
+ <GestureDetector gesture={gesture}>
185
+ <View
186
+ accessibilityActions={isReadOnly ? undefined : ACCESSIBILITY_ACTIONS}
187
+ accessibilityLabel="Rating"
188
+ accessibilityRole={isReadOnly ? "image" : "adjustable"}
189
+ accessibilityState={{ disabled: isDisabled }}
190
+ accessibilityValue={{ max: count, min: 0, now: value, text: describeRating(value, count) }}
191
+ accessible
192
+ className={slots.stars({ className })}
193
+ onAccessibilityAction={handleAccessibilityAction}
194
+ onLayout={handleLayout}
195
+ {...props}
196
+ >
197
+ {stars.map((index) => (
198
+ <View className={cellClassName} key={index}>
199
+ <RatingStar
200
+ clipClassName={clipClassName}
201
+ emptyColor={emptyColor}
202
+ fill={starFillOf(index, value)}
203
+ fillColor={fillColor}
204
+ glyphClassName={glyphClassName}
205
+ />
206
+ </View>
207
+ ))}
208
+ </View>
209
+ </GestureDetector>
210
+ );
211
+ }
212
+ RatingStars.displayName = "DelacourUI.Rating.Stars";
213
+
214
+ /**
215
+ * The two actions an assistive swipe on an `adjustable` maps to.
216
+ *
217
+ * Module scope on purpose: a fresh array each render is a new prop every commit.
218
+ */
219
+ const ACCESSIBILITY_ACTIONS = [{ name: "increment" }, { name: "decrement" }] as const;
@@ -0,0 +1,79 @@
1
+ import { createContext, type ReactElement, type ReactNode, use } from "react";
2
+ import type { HapticFeedback } from "../pressable";
3
+ import type { RatingColor, RatingSize } from "./rating.variants";
4
+
5
+ /** What a render function inside a rating is handed. */
6
+ export type RatingRenderProps = {
7
+ /** The value, clamped to the count but not snapped — a read-only 3.7 stays 3.7. */
8
+ value: number;
9
+ /** How many stars the row draws. */
10
+ count: number;
11
+ /** The increment a touch snaps to. */
12
+ step: number;
13
+ isDisabled: boolean;
14
+ isInvalid: boolean;
15
+ isReadOnly: boolean;
16
+ };
17
+
18
+ export type RatingContextValue = RatingRenderProps & {
19
+ color: RatingColor;
20
+ size: RatingSize;
21
+ /** A tap on the value already held clears it back to zero. */
22
+ allowClear: boolean;
23
+ /** Played on grab, on every star crossed and on a clear. `false` silences it. */
24
+ haptic: false | HapticFeedback;
25
+ /**
26
+ * Writes a new value from the **JS thread**. Called from the gesture's mirror,
27
+ * from an assistive action and from a clear — never in render.
28
+ */
29
+ setValue: (next: number) => void;
30
+ /** Reports the value a gesture or an assistive action settled on, once. */
31
+ commitEnd: (next: number) => void;
32
+ /** The settled state a render function is handed. */
33
+ renderProps: RatingRenderProps;
34
+ };
35
+
36
+ const RatingContext = createContext<RatingContextValue | null>(null);
37
+
38
+ /**
39
+ * Supplies one rating's settled state to its own parts.
40
+ *
41
+ * Lives in its own module, importing nothing but React and types, so a part can
42
+ * read it without importing `./rating` and closing a cycle (AGENTS.md rule 3).
43
+ */
44
+ export function RatingProvider({ value, children }: { value: RatingContextValue; children: ReactNode }): ReactElement {
45
+ return <RatingContext value={value}>{children}</RatingContext>;
46
+ }
47
+ RatingProvider.displayName = "DelacourUI.Rating.Provider";
48
+
49
+ /** The enclosing rating's state, or null outside a `<Rating>`. */
50
+ export function useRatingContext(): RatingContextValue | null {
51
+ return use(RatingContext);
52
+ }
53
+
54
+ /**
55
+ * Reads the enclosing rating's settled state.
56
+ *
57
+ * For a custom readout that has to match the rating it sits beside. Throws
58
+ * outside one — use {@link useRatingContext} where the rating is optional.
59
+ */
60
+ export function useRating(): RatingContextValue {
61
+ const context = useRatingContext();
62
+ if (!context) {
63
+ throw new Error("useRating must be called inside a <Rating>.");
64
+ }
65
+ return context;
66
+ }
67
+
68
+ /**
69
+ * The enclosing rating's state, for a compound part that cannot work without one.
70
+ *
71
+ * Internal: deliberately not re-exported from `index.ts`.
72
+ */
73
+ export function useRatingPart(component: string): RatingContextValue {
74
+ const context = useRatingContext();
75
+ if (!context) {
76
+ throw new Error(`${component} must be rendered inside a <Rating>.`);
77
+ }
78
+ return context;
79
+ }