panelui-native 0.19.0 → 0.21.0
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/lib/module/components/bottom-sheet/index.js +35 -10
- package/lib/module/components/bottom-sheet/index.js.map +1 -1
- package/lib/module/components/flow/flow-paths.js +18 -0
- package/lib/module/components/flow/flow-paths.js.map +1 -1
- package/lib/module/components/flow/index.js +156 -31
- package/lib/module/components/flow/index.js.map +1 -1
- package/lib/module/components/rating/index.js +339 -0
- package/lib/module/components/rating/index.js.map +1 -0
- package/lib/module/components/signature/index.js +7 -3
- package/lib/module/components/signature/index.js.map +1 -1
- package/lib/module/components/slider/index.js +14 -1
- package/lib/module/components/slider/index.js.map +1 -1
- package/lib/module/icons/index.js +58 -0
- package/lib/module/icons/index.js.map +1 -1
- package/lib/module/index.js +5 -0
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
- package/lib/typescript/src/components/flow/flow-paths.d.ts +8 -0
- package/lib/typescript/src/components/flow/flow-paths.d.ts.map +1 -1
- package/lib/typescript/src/components/flow/index.d.ts +13 -1
- package/lib/typescript/src/components/flow/index.d.ts.map +1 -1
- package/lib/typescript/src/components/rating/index.d.ts +122 -0
- package/lib/typescript/src/components/rating/index.d.ts.map +1 -0
- package/lib/typescript/src/components/signature/index.d.ts +4 -0
- package/lib/typescript/src/components/signature/index.d.ts.map +1 -1
- package/lib/typescript/src/components/slider/index.d.ts +6 -0
- package/lib/typescript/src/components/slider/index.d.ts.map +1 -1
- package/lib/typescript/src/icons/index.d.ts +10 -0
- package/lib/typescript/src/icons/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +2 -0
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/bottom-sheet/index.tsx +52 -21
- package/src/components/flow/flow-paths.ts +14 -0
- package/src/components/flow/index.tsx +197 -31
- package/src/components/rating/index.tsx +416 -0
- package/src/components/signature/index.tsx +13 -3
- package/src/components/slider/index.tsx +20 -1
- package/src/icons/index.tsx +49 -0
- package/src/index.ts +10 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rating — a row of stars you can read or set.
|
|
3
|
+
*
|
|
4
|
+
* A rating is two jobs wearing one shape. Read-only, it is a compact way to
|
|
5
|
+
* *show* a score — four-and-a-half stars next to a product. Interactive, it is
|
|
6
|
+
* an *input*: tap a star to pick a whole value, or with `precision={0.5}` press
|
|
7
|
+
* the left half of a star for a half. Either way the fill is the truth, so the
|
|
8
|
+
* two modes look identical and only differ in whether a finger changes them.
|
|
9
|
+
*
|
|
10
|
+
* Partial fills are drawn by clipping a filled star over an empty one of the
|
|
11
|
+
* same geometry — the filled layer's width is a fraction of the star's, so a
|
|
12
|
+
* half star is exactly half and any precision in between is honest. That fill
|
|
13
|
+
* is animated on the UI thread: setting a value springs the stars to it rather
|
|
14
|
+
* than snapping, and a drag follows the finger with no spring so it never lags.
|
|
15
|
+
*
|
|
16
|
+
* ```tsx
|
|
17
|
+
* <Rating defaultValue={3} onValueChange={setScore} />
|
|
18
|
+
*
|
|
19
|
+
* <Rating value={4.5} precision={0.5} readOnly />
|
|
20
|
+
*
|
|
21
|
+
* <Rating label="Rate your stay" showValue color="warning" defaultValue={0} />
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* Works controlled (`value` + `onValueChange`) or uncontrolled (`defaultValue`).
|
|
25
|
+
*/
|
|
26
|
+
import { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
|
|
27
|
+
import {
|
|
28
|
+
View,
|
|
29
|
+
type AccessibilityActionEvent,
|
|
30
|
+
type LayoutChangeEvent,
|
|
31
|
+
} from 'react-native';
|
|
32
|
+
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
|
33
|
+
import Animated, {
|
|
34
|
+
runOnJS,
|
|
35
|
+
useAnimatedStyle,
|
|
36
|
+
useSharedValue,
|
|
37
|
+
withSpring,
|
|
38
|
+
type SharedValue,
|
|
39
|
+
} from 'react-native-reanimated';
|
|
40
|
+
import { tv, type VariantProps } from 'tailwind-variants';
|
|
41
|
+
import { useCSSVariable } from 'uniwind';
|
|
42
|
+
import { StarIcon } from '../../icons';
|
|
43
|
+
import { Text } from '../../primitives/text';
|
|
44
|
+
import { cn } from '../../utils/cn';
|
|
45
|
+
import { selectionTick } from '../../utils/haptics';
|
|
46
|
+
|
|
47
|
+
/** Springs the fill onto its resting value after a tap or the end of a drag. */
|
|
48
|
+
const SPRING = { damping: 18, stiffness: 220, mass: 0.6 } as const;
|
|
49
|
+
|
|
50
|
+
const ratingVariants = tv({
|
|
51
|
+
slots: {
|
|
52
|
+
root: 'gap-2',
|
|
53
|
+
header: 'flex-row items-center justify-between gap-3',
|
|
54
|
+
label: 'text-sm font-medium text-foreground',
|
|
55
|
+
value: 'text-sm text-muted-foreground',
|
|
56
|
+
row: 'flex-row items-center self-start',
|
|
57
|
+
},
|
|
58
|
+
variants: {
|
|
59
|
+
size: {
|
|
60
|
+
sm: { row: 'gap-0.5' },
|
|
61
|
+
md: { row: 'gap-1' },
|
|
62
|
+
lg: { row: 'gap-1.5' },
|
|
63
|
+
},
|
|
64
|
+
disabled: {
|
|
65
|
+
true: { root: 'opacity-50' },
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
defaultVariants: {
|
|
69
|
+
size: 'md',
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
type RatingVariantProps = VariantProps<typeof ratingVariants>;
|
|
74
|
+
export type RatingSize = NonNullable<RatingVariantProps['size']>;
|
|
75
|
+
export type RatingColor =
|
|
76
|
+
| 'warning'
|
|
77
|
+
| 'primary'
|
|
78
|
+
| 'success'
|
|
79
|
+
| 'destructive'
|
|
80
|
+
| 'info'
|
|
81
|
+
| 'foreground';
|
|
82
|
+
|
|
83
|
+
/** Star pixel size per size token — the whole control scales from this. */
|
|
84
|
+
const STAR_SIZE: Record<RatingSize, number> = { sm: 18, md: 24, lg: 32 };
|
|
85
|
+
|
|
86
|
+
/** The CSS token each colour paints its filled stars with. */
|
|
87
|
+
const FILL_VAR: Record<RatingColor, string> = {
|
|
88
|
+
warning: '--color-warning',
|
|
89
|
+
primary: '--color-primary',
|
|
90
|
+
success: '--color-success',
|
|
91
|
+
destructive: '--color-destructive',
|
|
92
|
+
info: '--color-info',
|
|
93
|
+
foreground: '--color-foreground',
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
function clampJS(value: number, min: number, max: number) {
|
|
97
|
+
return Math.min(Math.max(value, min), max);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Rounds `value` up to the nearest `precision`, so any touch fills at least it. */
|
|
101
|
+
function snap(value: number, precision: number, max: number) {
|
|
102
|
+
const stepped = Math.ceil(value / precision) * precision;
|
|
103
|
+
// Guard the float drift that `ceil` on fractional steps can leave behind.
|
|
104
|
+
return clampJS(Math.round(stepped / precision) * precision, 0, max);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface RatingProps extends Omit<RatingVariantProps, 'disabled'> {
|
|
108
|
+
className?: string;
|
|
109
|
+
/** Controlled value. Leave unset and pass `defaultValue` to run uncontrolled. */
|
|
110
|
+
value?: number;
|
|
111
|
+
/** Starting value when uncontrolled. */
|
|
112
|
+
defaultValue?: number;
|
|
113
|
+
/** How many stars. */
|
|
114
|
+
max?: number;
|
|
115
|
+
/**
|
|
116
|
+
* Smallest step a tap can pick, as a fraction of a star. `1` is whole stars,
|
|
117
|
+
* `0.5` lets the left half of a star mean a half. Reading a value renders any
|
|
118
|
+
* precision — this only constrains what a finger can set.
|
|
119
|
+
*/
|
|
120
|
+
precision?: number;
|
|
121
|
+
/** Fires as the value changes, including live while dragging. */
|
|
122
|
+
onValueChange?: (value: number) => void;
|
|
123
|
+
/** Fires once when a tap or drag ends — the place for expensive side effects. */
|
|
124
|
+
onValueCommit?: (value: number) => void;
|
|
125
|
+
/** Show the stars but ignore touches — the display half of the component. */
|
|
126
|
+
readOnly?: boolean;
|
|
127
|
+
disabled?: boolean;
|
|
128
|
+
/**
|
|
129
|
+
* Let a second tap on the current value clear it back to zero, so a rating
|
|
130
|
+
* given by mistake can be taken back without a separate control.
|
|
131
|
+
*/
|
|
132
|
+
allowClear?: boolean;
|
|
133
|
+
/** Which token the filled stars are painted with. */
|
|
134
|
+
color?: RatingColor;
|
|
135
|
+
/** Caption above the stars. Also becomes the accessibility label. */
|
|
136
|
+
label?: string;
|
|
137
|
+
/** Show the numeric value on the caption row, opposite the label. */
|
|
138
|
+
showValue?: boolean;
|
|
139
|
+
/** Format the shown value. Defaults to the number as written. */
|
|
140
|
+
formatValue?: (value: number) => string;
|
|
141
|
+
/**
|
|
142
|
+
* A tick under the finger each time a drag crosses onto a new star. Off by
|
|
143
|
+
* default — needs the optional `expo-haptics`, and is silent without it.
|
|
144
|
+
*/
|
|
145
|
+
haptics?: boolean;
|
|
146
|
+
/** Extra classes for the caption row. */
|
|
147
|
+
headerClassName?: string;
|
|
148
|
+
/** Extra classes for the row of stars. */
|
|
149
|
+
rowClassName?: string;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* One star: an empty outline with a filled copy clipped over it. The clip width
|
|
154
|
+
* is driven by the shared `display` value on the UI thread, so the fill of this
|
|
155
|
+
* star animates and drags without a React render.
|
|
156
|
+
*/
|
|
157
|
+
function Star({
|
|
158
|
+
index,
|
|
159
|
+
display,
|
|
160
|
+
size,
|
|
161
|
+
gap,
|
|
162
|
+
fillColor,
|
|
163
|
+
emptyColor,
|
|
164
|
+
}: {
|
|
165
|
+
index: number;
|
|
166
|
+
display: SharedValue<number>;
|
|
167
|
+
size: number;
|
|
168
|
+
gap: number;
|
|
169
|
+
fillColor: string;
|
|
170
|
+
emptyColor: string;
|
|
171
|
+
}) {
|
|
172
|
+
// Clamped inline rather than through a helper: this body runs on the UI
|
|
173
|
+
// thread, which can only reach worklets, not module-scope functions.
|
|
174
|
+
const clipStyle = useAnimatedStyle(() => ({
|
|
175
|
+
width: Math.min(Math.max(display.value - index, 0), 1) * size,
|
|
176
|
+
}));
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<View style={{ width: size, height: size, marginRight: gap }}>
|
|
180
|
+
<StarIcon size={size} color={emptyColor} />
|
|
181
|
+
{/* The filled layer is pinned to the left and clipped to a fraction of
|
|
182
|
+
the star's width, so a partial value shows a partial star. */}
|
|
183
|
+
<Animated.View
|
|
184
|
+
style={[{ position: 'absolute', left: 0, top: 0, overflow: 'hidden' }, clipStyle]}
|
|
185
|
+
>
|
|
186
|
+
<StarIcon size={size} color={fillColor} filled />
|
|
187
|
+
</Animated.View>
|
|
188
|
+
</View>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export const Rating = forwardRef<View, RatingProps>(
|
|
193
|
+
(
|
|
194
|
+
{
|
|
195
|
+
className,
|
|
196
|
+
headerClassName,
|
|
197
|
+
rowClassName,
|
|
198
|
+
value: valueProp,
|
|
199
|
+
defaultValue = 0,
|
|
200
|
+
max = 5,
|
|
201
|
+
precision = 1,
|
|
202
|
+
onValueChange,
|
|
203
|
+
onValueCommit,
|
|
204
|
+
readOnly = false,
|
|
205
|
+
disabled = false,
|
|
206
|
+
allowClear = false,
|
|
207
|
+
color = 'warning',
|
|
208
|
+
size = 'md',
|
|
209
|
+
label,
|
|
210
|
+
showValue = false,
|
|
211
|
+
formatValue,
|
|
212
|
+
haptics = false,
|
|
213
|
+
},
|
|
214
|
+
ref
|
|
215
|
+
) => {
|
|
216
|
+
const isControlled = valueProp !== undefined;
|
|
217
|
+
const [internal, setInternal] = useState(defaultValue);
|
|
218
|
+
const value = clampJS(isControlled ? valueProp! : internal, 0, max);
|
|
219
|
+
|
|
220
|
+
const interactive = !readOnly && !disabled;
|
|
221
|
+
const slots = ratingVariants({ size, disabled });
|
|
222
|
+
const starSize = STAR_SIZE[size ?? 'md'];
|
|
223
|
+
const gap = size === 'sm' ? 2 : size === 'lg' ? 6 : 4;
|
|
224
|
+
|
|
225
|
+
// SVG paints with real colour strings, not classes — resolve the tokens.
|
|
226
|
+
const fillColor = useCSSVariable(FILL_VAR[color]);
|
|
227
|
+
const emptyColor = useCSSVariable('--color-border');
|
|
228
|
+
const fill = typeof fillColor === 'string' ? fillColor : '#f59e0b';
|
|
229
|
+
const empty = typeof emptyColor === 'string' ? emptyColor : '#d4d4d4';
|
|
230
|
+
|
|
231
|
+
// Drives every star's clipped fill on the UI thread.
|
|
232
|
+
const display = useSharedValue(value);
|
|
233
|
+
const rowWidth = useSharedValue(0);
|
|
234
|
+
|
|
235
|
+
// Keep the change/commit handlers reachable from the gesture without
|
|
236
|
+
// rebuilding it on every render.
|
|
237
|
+
const changeRef = useRef(onValueChange);
|
|
238
|
+
changeRef.current = onValueChange;
|
|
239
|
+
const commitRef = useRef(onValueCommit);
|
|
240
|
+
commitRef.current = onValueCommit;
|
|
241
|
+
// The last whole star a drag crossed, so haptics tick once per star.
|
|
242
|
+
const lastStep = useRef(Math.ceil(value));
|
|
243
|
+
|
|
244
|
+
const emitChange = useCallback(
|
|
245
|
+
(next: number) => {
|
|
246
|
+
if (!isControlled) setInternal(next);
|
|
247
|
+
changeRef.current?.(next);
|
|
248
|
+
},
|
|
249
|
+
[isControlled]
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
// Follow a controlled value that changes elsewhere, springing to it.
|
|
253
|
+
useEffect(() => {
|
|
254
|
+
display.value = withSpring(value, SPRING);
|
|
255
|
+
}, [value, display]);
|
|
256
|
+
|
|
257
|
+
const onRowLayout = (event: LayoutChangeEvent) => {
|
|
258
|
+
rowWidth.value = event.nativeEvent.layout.width;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
// Maps an x within the row to a snapped rating. Runs on JS off the worklet.
|
|
262
|
+
const valueFromX = useCallback(
|
|
263
|
+
(x: number, width: number) => {
|
|
264
|
+
if (width <= 0) return value;
|
|
265
|
+
const raw = clampJS(x / width, 0, 1) * max;
|
|
266
|
+
return snap(raw, precision, max);
|
|
267
|
+
},
|
|
268
|
+
[max, precision, value]
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
const applyLive = useCallback(
|
|
272
|
+
(x: number, width: number) => {
|
|
273
|
+
let next = valueFromX(x, width);
|
|
274
|
+
if (allowClear && next === value && Math.ceil(next) === lastStep.current) {
|
|
275
|
+
next = 0;
|
|
276
|
+
}
|
|
277
|
+
display.value = next;
|
|
278
|
+
if (haptics && Math.ceil(next) !== lastStep.current) {
|
|
279
|
+
lastStep.current = Math.ceil(next);
|
|
280
|
+
selectionTick();
|
|
281
|
+
}
|
|
282
|
+
emitChange(next);
|
|
283
|
+
},
|
|
284
|
+
[valueFromX, allowClear, value, display, haptics, emitChange]
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
// Resets the haptic step to where the touch started, then applies the first
|
|
288
|
+
// position. Runs on JS: a ref cannot be written from a gesture worklet.
|
|
289
|
+
const begin = useCallback(
|
|
290
|
+
(x: number, width: number) => {
|
|
291
|
+
lastStep.current = Math.ceil(value);
|
|
292
|
+
applyLive(x, width);
|
|
293
|
+
},
|
|
294
|
+
[value, applyLive]
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
const commit = useCallback(
|
|
298
|
+
(x: number, width: number) => {
|
|
299
|
+
let next = valueFromX(x, width);
|
|
300
|
+
if (allowClear && next === value) next = 0;
|
|
301
|
+
display.value = withSpring(next, SPRING);
|
|
302
|
+
lastStep.current = Math.ceil(next);
|
|
303
|
+
emitChange(next);
|
|
304
|
+
commitRef.current?.(next);
|
|
305
|
+
},
|
|
306
|
+
[valueFromX, allowClear, value, display, emitChange]
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
const pan = Gesture.Pan()
|
|
310
|
+
.enabled(interactive)
|
|
311
|
+
.onBegin((event) => {
|
|
312
|
+
runOnJS(begin)(event.x, rowWidth.value);
|
|
313
|
+
})
|
|
314
|
+
.onUpdate((event) => {
|
|
315
|
+
runOnJS(applyLive)(event.x, rowWidth.value);
|
|
316
|
+
})
|
|
317
|
+
.onFinalize((event) => {
|
|
318
|
+
runOnJS(commit)(event.x, rowWidth.value);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
const tap = Gesture.Tap()
|
|
322
|
+
.enabled(interactive)
|
|
323
|
+
.maxDuration(250)
|
|
324
|
+
.onEnd((event) => {
|
|
325
|
+
runOnJS(commit)(event.x, rowWidth.value);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
const gesture = Gesture.Race(tap, pan);
|
|
329
|
+
|
|
330
|
+
// VoiceOver / TalkBack move by a single precision step.
|
|
331
|
+
const nudge = (dir: 1 | -1) => {
|
|
332
|
+
const next = clampJS(
|
|
333
|
+
Math.round((value + dir * precision) / precision) * precision,
|
|
334
|
+
0,
|
|
335
|
+
max
|
|
336
|
+
);
|
|
337
|
+
if (next === value) return;
|
|
338
|
+
display.value = withSpring(next, SPRING);
|
|
339
|
+
lastStep.current = Math.ceil(next);
|
|
340
|
+
emitChange(next);
|
|
341
|
+
commitRef.current?.(next);
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const onAccessibilityAction = (event: AccessibilityActionEvent) => {
|
|
345
|
+
if (event.nativeEvent.actionName === 'increment') nudge(1);
|
|
346
|
+
else if (event.nativeEvent.actionName === 'decrement') nudge(-1);
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const shown = formatValue ? formatValue(value) : String(value);
|
|
350
|
+
const a11yLabel = label ?? `Rating, ${shown} out of ${max}`;
|
|
351
|
+
|
|
352
|
+
const stars = (
|
|
353
|
+
<View
|
|
354
|
+
className={slots.row({ className: rowClassName })}
|
|
355
|
+
onLayout={onRowLayout}
|
|
356
|
+
// The row is measured for the touch math, so it must not collapse.
|
|
357
|
+
collapsable={false}
|
|
358
|
+
>
|
|
359
|
+
{Array.from({ length: max }, (_, i) => (
|
|
360
|
+
<Star
|
|
361
|
+
key={i}
|
|
362
|
+
index={i}
|
|
363
|
+
display={display}
|
|
364
|
+
size={starSize}
|
|
365
|
+
// No trailing gap after the last star, so the row width is honest.
|
|
366
|
+
gap={i === max - 1 ? 0 : gap}
|
|
367
|
+
fillColor={fill}
|
|
368
|
+
emptyColor={empty}
|
|
369
|
+
/>
|
|
370
|
+
))}
|
|
371
|
+
</View>
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
const header =
|
|
375
|
+
label || showValue ? (
|
|
376
|
+
<View className={slots.header({ className: headerClassName })}>
|
|
377
|
+
{label ? <Text className={slots.label()}>{label}</Text> : <View />}
|
|
378
|
+
{showValue ? <Text className={slots.value()}>{shown}</Text> : null}
|
|
379
|
+
</View>
|
|
380
|
+
) : null;
|
|
381
|
+
|
|
382
|
+
return (
|
|
383
|
+
<View ref={ref} className={cn(slots.root({ className }))}>
|
|
384
|
+
{header}
|
|
385
|
+
{interactive ? (
|
|
386
|
+
<GestureDetector gesture={gesture}>
|
|
387
|
+
<View
|
|
388
|
+
accessible
|
|
389
|
+
accessibilityRole="adjustable"
|
|
390
|
+
accessibilityLabel={a11yLabel}
|
|
391
|
+
accessibilityState={{ disabled }}
|
|
392
|
+
accessibilityValue={{ min: 0, max, now: value, text: shown }}
|
|
393
|
+
accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
|
|
394
|
+
onAccessibilityAction={onAccessibilityAction}
|
|
395
|
+
// Hold the stars in a self-starting box so the gesture area is
|
|
396
|
+
// exactly the stars, not the full width of the parent.
|
|
397
|
+
className="self-start"
|
|
398
|
+
>
|
|
399
|
+
{stars}
|
|
400
|
+
</View>
|
|
401
|
+
</GestureDetector>
|
|
402
|
+
) : (
|
|
403
|
+
<View
|
|
404
|
+
accessible
|
|
405
|
+
accessibilityRole="image"
|
|
406
|
+
accessibilityLabel={a11yLabel}
|
|
407
|
+
>
|
|
408
|
+
{stars}
|
|
409
|
+
</View>
|
|
410
|
+
)}
|
|
411
|
+
</View>
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
Rating.displayName = 'Rating';
|
|
@@ -68,7 +68,13 @@ import Animated, {
|
|
|
68
68
|
} from 'react-native-reanimated';
|
|
69
69
|
import Svg, { Path } from 'react-native-svg';
|
|
70
70
|
import { tv, type VariantProps } from 'tailwind-variants';
|
|
71
|
-
import {
|
|
71
|
+
import {
|
|
72
|
+
PencilIcon,
|
|
73
|
+
RotateCcwIcon,
|
|
74
|
+
RotateCwIcon,
|
|
75
|
+
TrashIcon,
|
|
76
|
+
type IconProps,
|
|
77
|
+
} from '../../icons';
|
|
72
78
|
import {
|
|
73
79
|
AnimatedPressable,
|
|
74
80
|
type AnimatedPressableProps,
|
|
@@ -631,8 +637,8 @@ export interface SignatureButtonProps
|
|
|
631
637
|
}
|
|
632
638
|
|
|
633
639
|
/**
|
|
634
|
-
* The
|
|
635
|
-
* different spoken name, so they are built rather than written
|
|
640
|
+
* The pad controls are all the same round button with a different icon and a
|
|
641
|
+
* different spoken name, so they are built rather than written out each time.
|
|
636
642
|
*/
|
|
637
643
|
function circleButton(
|
|
638
644
|
Icon: ComponentType<IconProps>,
|
|
@@ -671,11 +677,15 @@ function circleButton(
|
|
|
671
677
|
/** Removes the last stroke. Wire it to `ref.current?.undo()`. */
|
|
672
678
|
const SignatureUndo = circleButton(RotateCcwIcon, 'Undo last stroke', 'Signature.Undo');
|
|
673
679
|
|
|
680
|
+
/** Puts back the last undone stroke. Wire it to `ref.current?.redo()`. */
|
|
681
|
+
const SignatureRedo = circleButton(RotateCwIcon, 'Redo last stroke', 'Signature.Redo');
|
|
682
|
+
|
|
674
683
|
/** Drops every stroke. Wire it to `ref.current?.clear()`. */
|
|
675
684
|
const SignatureClear = circleButton(TrashIcon, 'Clear signature', 'Signature.Clear');
|
|
676
685
|
|
|
677
686
|
export const Signature = Object.assign(SignatureForwarded, {
|
|
678
687
|
Toolbar: SignatureToolbar,
|
|
679
688
|
Undo: SignatureUndo,
|
|
689
|
+
Redo: SignatureRedo,
|
|
680
690
|
Clear: SignatureClear,
|
|
681
691
|
});
|
|
@@ -36,6 +36,7 @@ import Animated, {
|
|
|
36
36
|
import { tv, type VariantProps } from 'tailwind-variants';
|
|
37
37
|
import { getNativeUI } from '../../native';
|
|
38
38
|
import { Text } from '../../primitives/text';
|
|
39
|
+
import { selectionTick } from '../../utils/haptics';
|
|
39
40
|
|
|
40
41
|
/**
|
|
41
42
|
* Springs the thumb onto its resting step after a drag or a track tap. Drags
|
|
@@ -127,6 +128,12 @@ export interface SliderProps extends Omit<SliderVariantProps, 'disabled'> {
|
|
|
127
128
|
showValue?: boolean;
|
|
128
129
|
/** Format the shown value. Defaults to the rounded number. */
|
|
129
130
|
formatValue?: (value: number) => string;
|
|
131
|
+
/**
|
|
132
|
+
* A tick under the finger each time a drag crosses onto a new step, and once
|
|
133
|
+
* more when the drag ends. Off by default — needs the optional `expo-haptics`,
|
|
134
|
+
* and is silent without it.
|
|
135
|
+
*/
|
|
136
|
+
haptics?: boolean;
|
|
130
137
|
/** Extra classes for the caption row. */
|
|
131
138
|
headerClassName?: string;
|
|
132
139
|
/** Extra classes for the unfilled track. */
|
|
@@ -170,6 +177,7 @@ export const Slider = forwardRef<View, SliderProps>(
|
|
|
170
177
|
label,
|
|
171
178
|
showValue = false,
|
|
172
179
|
formatValue,
|
|
180
|
+
haptics = false,
|
|
173
181
|
color = 'primary',
|
|
174
182
|
size = 'md',
|
|
175
183
|
},
|
|
@@ -197,6 +205,9 @@ export const Slider = forwardRef<View, SliderProps>(
|
|
|
197
205
|
changeRef.current = onValueChange;
|
|
198
206
|
const commitRef = useRef(onValueCommit);
|
|
199
207
|
commitRef.current = onValueCommit;
|
|
208
|
+
// The last step a drag ticked on, so haptics fire once per step, not per
|
|
209
|
+
// pixel of movement within it.
|
|
210
|
+
const lastTick = useRef(value);
|
|
200
211
|
|
|
201
212
|
const emitChange = useCallback(
|
|
202
213
|
(next: number) => {
|
|
@@ -223,10 +234,14 @@ export const Slider = forwardRef<View, SliderProps>(
|
|
|
223
234
|
(p: number, commit: boolean) => {
|
|
224
235
|
const raw = min + p * (max - min);
|
|
225
236
|
const snapped = snap(raw, min, max, step);
|
|
237
|
+
if (haptics && snapped !== lastTick.current) {
|
|
238
|
+
lastTick.current = snapped;
|
|
239
|
+
selectionTick();
|
|
240
|
+
}
|
|
226
241
|
emitChange(snapped);
|
|
227
242
|
if (commit) emitCommit(snapped);
|
|
228
243
|
},
|
|
229
|
-
[min, max, step, emitChange, emitCommit]
|
|
244
|
+
[min, max, step, haptics, emitChange, emitCommit]
|
|
230
245
|
);
|
|
231
246
|
|
|
232
247
|
const pan = Gesture.Pan()
|
|
@@ -280,6 +295,10 @@ export const Slider = forwardRef<View, SliderProps>(
|
|
|
280
295
|
const nudge = (dir: 1 | -1) => {
|
|
281
296
|
const next = snap(value + dir * (step || 1), min, max, step);
|
|
282
297
|
if (next === value) return;
|
|
298
|
+
if (haptics) {
|
|
299
|
+
lastTick.current = next;
|
|
300
|
+
selectionTick();
|
|
301
|
+
}
|
|
283
302
|
progress.value = withSpring(max > min ? (next - min) / (max - min) : 0, SPRING);
|
|
284
303
|
emitChange(next);
|
|
285
304
|
emitCommit(next);
|
package/src/icons/index.tsx
CHANGED
|
@@ -562,6 +562,32 @@ export function PauseIcon({ size = 16, color, ...props }: IconProps) {
|
|
|
562
562
|
);
|
|
563
563
|
}
|
|
564
564
|
|
|
565
|
+
/**
|
|
566
|
+
* A five-pointed star — the mark of a Rating. `filled` paints the interior;
|
|
567
|
+
* left hollow it is the outline of an unearned star. Both share the same
|
|
568
|
+
* geometry, so a filled star clipped over an empty one lines up to the pixel.
|
|
569
|
+
*/
|
|
570
|
+
export function StarIcon({
|
|
571
|
+
size = 20,
|
|
572
|
+
color,
|
|
573
|
+
filled = false,
|
|
574
|
+
...props
|
|
575
|
+
}: IconProps & { filled?: boolean }) {
|
|
576
|
+
const resolved = useResolvedColor(color, '#737373');
|
|
577
|
+
return (
|
|
578
|
+
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none" {...props}>
|
|
579
|
+
<Path
|
|
580
|
+
d="M12 2.5l2.9 5.88 6.49.94-4.7 4.58 1.11 6.46L12 17.82 6.2 20.86l1.11-6.46-4.7-4.58 6.49-.94z"
|
|
581
|
+
fill={filled ? resolved : 'none'}
|
|
582
|
+
stroke={resolved}
|
|
583
|
+
strokeWidth={1.6}
|
|
584
|
+
strokeLinejoin="round"
|
|
585
|
+
strokeLinecap="round"
|
|
586
|
+
/>
|
|
587
|
+
</Svg>
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
|
|
565
591
|
/** A pencil at an angle — draw, sign, edit by hand. */
|
|
566
592
|
export function PencilIcon({ size = 16, color, ...props }: IconProps) {
|
|
567
593
|
const resolved = useResolvedColor(color, '#737373');
|
|
@@ -601,6 +627,29 @@ export function RotateCcwIcon({ size = 16, color, ...props }: IconProps) {
|
|
|
601
627
|
);
|
|
602
628
|
}
|
|
603
629
|
|
|
630
|
+
/** An arrow curling clockwise — redo, put back what was undone. */
|
|
631
|
+
export function RotateCwIcon({ size = 16, color, ...props }: IconProps) {
|
|
632
|
+
const resolved = useResolvedColor(color, '#737373');
|
|
633
|
+
return (
|
|
634
|
+
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none" {...props}>
|
|
635
|
+
<Path
|
|
636
|
+
d="M21 12a9 9 0 1 1-2.64-6.36L21 8"
|
|
637
|
+
stroke={resolved}
|
|
638
|
+
strokeWidth={2}
|
|
639
|
+
strokeLinecap="round"
|
|
640
|
+
strokeLinejoin="round"
|
|
641
|
+
/>
|
|
642
|
+
<Path
|
|
643
|
+
d="M21 3v5h-5"
|
|
644
|
+
stroke={resolved}
|
|
645
|
+
strokeWidth={2}
|
|
646
|
+
strokeLinecap="round"
|
|
647
|
+
strokeLinejoin="round"
|
|
648
|
+
/>
|
|
649
|
+
</Svg>
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
|
|
604
653
|
/** A bin — discard what is there. */
|
|
605
654
|
export function TrashIcon({ size = 16, color, ...props }: IconProps) {
|
|
606
655
|
const resolved = useResolvedColor(color, '#737373');
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,10 @@ export {
|
|
|
16
16
|
|
|
17
17
|
// Primitives
|
|
18
18
|
export { Portal, PortalHost, PortalProvider } from './primitives/portal';
|
|
19
|
+
// The companion to Portal: the layer that makes the screen behind an overlay
|
|
20
|
+
// recede. Public so a custom overlay gets the same backdrop — and the same
|
|
21
|
+
// graceful fall back from blur to dim — as the built-in ones.
|
|
22
|
+
export { Scrim, hasBlur, type ScrimProps } from './primitives/scrim';
|
|
19
23
|
export { Text, type TextProps } from './primitives/text';
|
|
20
24
|
export {
|
|
21
25
|
KeyboardAvoider,
|
|
@@ -302,6 +306,12 @@ export {
|
|
|
302
306
|
type RadioGroupProps,
|
|
303
307
|
type RadioGroupItemProps,
|
|
304
308
|
} from './components/radio-group';
|
|
309
|
+
export {
|
|
310
|
+
Rating,
|
|
311
|
+
type RatingProps,
|
|
312
|
+
type RatingColor,
|
|
313
|
+
type RatingSize,
|
|
314
|
+
} from './components/rating';
|
|
305
315
|
export {
|
|
306
316
|
ScrollFade,
|
|
307
317
|
type ScrollFadeProps,
|