@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 +4 -2
- package/src/components/rating/AGENTS.md +143 -0
- package/src/components/rating/index.ts +43 -0
- package/src/components/rating/rating-output.tsx +39 -0
- package/src/components/rating/rating-star.tsx +70 -0
- package/src/components/rating/rating-stars.tsx +219 -0
- package/src/components/rating/rating.context.tsx +79 -0
- package/src/components/rating/rating.tsx +163 -0
- package/src/components/rating/rating.variants.test.ts +347 -0
- package/src/components/rating/rating.variants.ts +354 -0
- package/src/components/steps/AGENTS.md +153 -0
- package/src/components/steps/index.ts +45 -0
- package/src/components/steps/steps-connector.tsx +31 -0
- package/src/components/steps/steps-description.tsx +26 -0
- package/src/components/steps/steps-indicator.tsx +106 -0
- package/src/components/steps/steps-item.tsx +225 -0
- package/src/components/steps/steps-panel.tsx +41 -0
- package/src/components/steps/steps-title.tsx +32 -0
- package/src/components/steps/steps-track.tsx +59 -0
- package/src/components/steps/steps.context.tsx +123 -0
- package/src/components/steps/steps.tsx +166 -0
- package/src/components/steps/steps.variants.test.ts +388 -0
- package/src/components/steps/steps.variants.ts +468 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@delacour/react-native-ui",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
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.
|
|
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
|
+
}
|