react-raffle-picker 0.3.1 → 0.4.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/README.md +52 -14
- package/dist/index.cjs +291 -0
- package/dist/index.d.cts +72 -1
- package/dist/index.d.mts +72 -1
- package/dist/index.mjs +291 -1
- package/package.json +19 -9
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
<h1>react-raffle-picker</h1>
|
|
5
5
|
|
|
6
|
-
<p><strong>React
|
|
6
|
+
<p><strong>Random winner picker for React.</strong><br/>Giveaways, raffles, prize draws and slot-machine UIs — headless, typed, zero dependencies.</p>
|
|
7
7
|
|
|
8
8
|
<p>
|
|
9
9
|
<a href="https://react-raffle-one.vercel.app/">Live demo</a>
|
|
@@ -27,7 +27,16 @@
|
|
|
27
27
|
</p>
|
|
28
28
|
</div>
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
Pick a random winner in React. `react-raffle-picker` cycles a list of names or a number range,
|
|
31
|
+
freezes on a winner, and never draws the same one twice — with slot-machine reels, four CSS
|
|
32
|
+
animations and countdown auto-freeze.
|
|
33
|
+
|
|
34
|
+
It ships as a **headless compound component**: no layout opinions, no style props soup, you
|
|
35
|
+
bring your own CSS. Performant on slow devices — high-frequency tick updates bypass React and
|
|
36
|
+
write to the DOM via refs.
|
|
37
|
+
|
|
38
|
+
**Use it for:** giveaway winners on stream · prize draws · random name picker · standup speaking
|
|
39
|
+
order · random number roller · slot-machine and lottery UIs.
|
|
31
40
|
|
|
32
41
|
```bash
|
|
33
42
|
npm install react-raffle-picker
|
|
@@ -71,7 +80,6 @@ This means:
|
|
|
71
80
|
|
|
72
81
|
```tsx
|
|
73
82
|
import { RafflePick } from 'react-raffle-picker'
|
|
74
|
-
|
|
75
83
|
;<RafflePick min={1} max={100} interval={100} inertia onSelect={(v) => console.log(v)}>
|
|
76
84
|
<RafflePick.Value animation="roll" className="my-value" />
|
|
77
85
|
<RafflePick.Button startLabel="Pick" stopLabel="Stop" />
|
|
@@ -107,11 +115,11 @@ Provides context. Renders an optional wrapper element (`as` prop, default `'div'
|
|
|
107
115
|
| `random` | `boolean` | `true` | Random pick vs sequential. |
|
|
108
116
|
| `inertia` | `boolean` | `false` | Soft start / soft stop ramp. |
|
|
109
117
|
| `autoStart` | `boolean` | `true` | Begin cycling on mount. |
|
|
110
|
-
| `noRepeat` | `boolean` | `true` | Exclude previously frozen values from later rounds — no duplicate winners across sequential draws in the same mounted instance. Set `false` to allow repeats.
|
|
118
|
+
| `noRepeat` | `boolean` | `true` | Exclude previously frozen values from later rounds — no duplicate winners across sequential draws in the same mounted instance. Set `false` to allow repeats. |
|
|
111
119
|
| `initialValue` | `number \| string` | — | Starting display before first run. Number for `min`/`max` mode, string for `items` mode. For `<Slots>`, each character seeds the corresponding reel. |
|
|
112
120
|
| `finalValue` | `number \| string` | — | Forces settle to land on this value. Cycle still appears random; only final freeze is rigged. For `<Slots>`, each character is the final char of the corresponding reel. |
|
|
113
121
|
| `onSelect` | `(value) => void` | — | Fires once per round on freeze. |
|
|
114
|
-
| `onExhausted` | `() => void` | — | Fires when `start()` is called but `noRepeat` has already drawn every candidate.
|
|
122
|
+
| `onExhausted` | `() => void` | — | Fires when `start()` is called but `noRepeat` has already drawn every candidate. |
|
|
115
123
|
| `as` | `ElementType` | `'div'` | Wrapper tag. |
|
|
116
124
|
| `className` | `string` | — | Wrapper class. |
|
|
117
125
|
| `style` | `CSSProperties` | — | Wrapper style. |
|
|
@@ -136,15 +144,15 @@ Multiple `Value` instances inside one root are supported — all subscribe to th
|
|
|
136
144
|
|
|
137
145
|
Toggles start / freeze based on phase. Disabled during settling.
|
|
138
146
|
|
|
139
|
-
| Prop | Type | Notes
|
|
140
|
-
| ------------ | --------------- |
|
|
141
|
-
| `startLabel` | `ReactNode` | Shown in `idle` / `frozen` (click starts).
|
|
142
|
-
| `stopLabel` | `ReactNode` | Shown in `running` / `starting` (click stops).
|
|
143
|
-
| `waitLabel` | `ReactNode` | Shown in `settling` (button disabled).
|
|
144
|
-
| `children` | `ReactNode` | Fallback label when state-specific label absent.
|
|
147
|
+
| Prop | Type | Notes |
|
|
148
|
+
| ------------ | --------------- | --------------------------------------------------------------- |
|
|
149
|
+
| `startLabel` | `ReactNode` | Shown in `idle` / `frozen` (click starts). |
|
|
150
|
+
| `stopLabel` | `ReactNode` | Shown in `running` / `starting` (click stops). |
|
|
151
|
+
| `waitLabel` | `ReactNode` | Shown in `settling` (button disabled). |
|
|
152
|
+
| `children` | `ReactNode` | Fallback label when state-specific label absent. |
|
|
145
153
|
| `disabled` | `boolean` | External disable, on top of the auto-disable during `settling`. |
|
|
146
|
-
| `className` | `string` | —
|
|
147
|
-
| `style` | `CSSProperties` | —
|
|
154
|
+
| `className` | `string` | — |
|
|
155
|
+
| `style` | `CSSProperties` | — |
|
|
148
156
|
|
|
149
157
|
### `<RafflePick.Countdown>`
|
|
150
158
|
|
|
@@ -172,6 +180,36 @@ Independent multi-reel slot machine. Each reel ticks on its own and stops with a
|
|
|
172
180
|
| `onResult` | `(joined: string) => void` | — | Fires when the last reel lands. |
|
|
173
181
|
| `className`, `slotClassName`, `style`, `slotStyle` | various | — | Style hooks. |
|
|
174
182
|
|
|
183
|
+
### `<RafflePick.Wheel>`
|
|
184
|
+
|
|
185
|
+
Spinning wheel that lands on the value the root picks. Segments come from `items`, or from the `min`/`max` range in numeric mode.
|
|
186
|
+
|
|
187
|
+
Needs no stylesheet — the geometry is inline SVG, rotation runs on `requestAnimationFrame`, and the landing is a single CSS transition, so nothing re-renders mid-spin. Respects `prefers-reduced-motion` by jumping straight to the result.
|
|
188
|
+
|
|
189
|
+
> **Reveal on `onResult`, not `onSelect`.** The root commits the winner the instant it freezes, which is when the wheel *starts* its landing. Firing confetti or a result banner from the root's `onSelect` shows the answer `spinDuration` ms before the wheel gets there.
|
|
190
|
+
|
|
191
|
+
| Prop | Type | Default | Notes |
|
|
192
|
+
| --------------- | -------------------------- | ---------------- | -------------------------------------------------------------------------------------------- |
|
|
193
|
+
| `segments` | `string[]` | root pool | Relabel segments. The winner is still chosen by the root; label `i` maps to pool position `i`. |
|
|
194
|
+
| `size` | `number` | `320` | Outer diameter, px. |
|
|
195
|
+
| `colors` | `string[]` | 6-color palette | Segment fills, cycled. |
|
|
196
|
+
| `labelColor` | `string` | `'#fff'` | Label text color. |
|
|
197
|
+
| `maxLabels` | `number` | `40` | Past this many segments labels are hidden — they overlap into noise. Segments still work. |
|
|
198
|
+
| `spinDuration` | `number` (ms) | `4200` | Landing animation length, from freeze to rest. |
|
|
199
|
+
| `turns` | `number` | `4` | Extra full revolutions before the landing angle. |
|
|
200
|
+
| `spinInterval` | `number` (ms per turn) | `1800` | Free-spin speed while running. |
|
|
201
|
+
| `pointer` | `'top' \| 'right'` | `'top'` | Where the pointer sits on the rim. |
|
|
202
|
+
| `hidePointer` | `boolean` | `false` | Drop the built-in triangle to render your own. |
|
|
203
|
+
| `onResult` | `(value) => void` | — | Fires when the wheel comes to rest. |
|
|
204
|
+
| `className`, `style` | various | — | Style hooks. |
|
|
205
|
+
|
|
206
|
+
```tsx
|
|
207
|
+
<RafflePick items={['Alice', 'Bob', 'Carol']} autoStart={false}>
|
|
208
|
+
<RafflePick.Wheel size={340} onResult={(winner) => celebrate(winner)} />
|
|
209
|
+
<RafflePick.Button startLabel="Spin" stopLabel="Stop" />
|
|
210
|
+
</RafflePick>
|
|
211
|
+
```
|
|
212
|
+
|
|
175
213
|
## Recipes
|
|
176
214
|
|
|
177
215
|
### Inline chip in a sentence
|
|
@@ -260,7 +298,7 @@ function Giveaway() {
|
|
|
260
298
|
## Accessibility
|
|
261
299
|
|
|
262
300
|
- `<RafflePick.Value>` cycles are visual-only (`aria-hidden`) — high-frequency tick
|
|
263
|
-
updates are not announced. The frozen result
|
|
301
|
+
updates are not announced. The frozen result _is_ announced once per round via a
|
|
264
302
|
hidden `aria-live="polite"` region.
|
|
265
303
|
- `<RafflePick.Countdown>`'s ring/label are `aria-hidden`; a one-time sr-only
|
|
266
304
|
announcement fires when the countdown starts. The result itself is still
|
package/dist/index.cjs
CHANGED
|
@@ -205,6 +205,11 @@ function RafflePickRoot({ items, min = 1, max = 100, interval = 100, random = tr
|
|
|
205
205
|
const its = itemsRef.current;
|
|
206
206
|
return its && its.length > 0 ? its[index] : index;
|
|
207
207
|
}, []);
|
|
208
|
+
const valueAt = (0, react.useCallback)((position) => {
|
|
209
|
+
const its = itemsRef.current;
|
|
210
|
+
if (its && its.length > 0) return its[position];
|
|
211
|
+
return cycleMin + position;
|
|
212
|
+
}, [cycleMin]);
|
|
208
213
|
const valueToIndex = (0, react.useCallback)((value) => {
|
|
209
214
|
if (value === void 0) return void 0;
|
|
210
215
|
const its = itemsRef.current;
|
|
@@ -320,8 +325,10 @@ function RafflePickRoot({ items, min = 1, max = 100, interval = 100, random = tr
|
|
|
320
325
|
noRepeat,
|
|
321
326
|
exhausted,
|
|
322
327
|
remaining,
|
|
328
|
+
total: totalCandidates,
|
|
323
329
|
valueRef,
|
|
324
330
|
displayValue,
|
|
331
|
+
valueAt,
|
|
325
332
|
subscribe,
|
|
326
333
|
start: guardedStart,
|
|
327
334
|
freeze,
|
|
@@ -340,7 +347,9 @@ function RafflePickRoot({ items, min = 1, max = 100, interval = 100, random = tr
|
|
|
340
347
|
noRepeat,
|
|
341
348
|
exhausted,
|
|
342
349
|
remaining,
|
|
350
|
+
totalCandidates,
|
|
343
351
|
displayValue,
|
|
352
|
+
valueAt,
|
|
344
353
|
subscribe,
|
|
345
354
|
guardedStart,
|
|
346
355
|
freeze,
|
|
@@ -718,12 +727,293 @@ function RafflePickSlots({ length = 3, chars = "0123456789", spinInterval = 80,
|
|
|
718
727
|
});
|
|
719
728
|
}
|
|
720
729
|
//#endregion
|
|
730
|
+
//#region src/components/RafflePick/RafflePickWheel.tsx
|
|
731
|
+
const DEFAULT_COLORS = [
|
|
732
|
+
"#7c1d29",
|
|
733
|
+
"#d4a04a",
|
|
734
|
+
"#2f4858",
|
|
735
|
+
"#a83b2c",
|
|
736
|
+
"#ecc878",
|
|
737
|
+
"#4a0f18"
|
|
738
|
+
];
|
|
739
|
+
const VIEWBOX = 100;
|
|
740
|
+
const CENTER = VIEWBOX / 2;
|
|
741
|
+
const RADIUS = 48;
|
|
742
|
+
const LABEL_RADIUS = RADIUS * .66;
|
|
743
|
+
const prefersReducedMotion = () => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
744
|
+
/** Point on the wheel rim at `deg`, measured clockwise from 12 o'clock. */
|
|
745
|
+
const rimPoint = (deg, radius) => {
|
|
746
|
+
const rad = (deg - 90) * Math.PI / 180;
|
|
747
|
+
return {
|
|
748
|
+
x: CENTER + radius * Math.cos(rad),
|
|
749
|
+
y: CENTER + radius * Math.sin(rad)
|
|
750
|
+
};
|
|
751
|
+
};
|
|
752
|
+
const segmentPath = (index, total) => {
|
|
753
|
+
const sweep = 360 / total;
|
|
754
|
+
const a0 = index * sweep;
|
|
755
|
+
const a1 = a0 + sweep;
|
|
756
|
+
const p0 = rimPoint(a0, RADIUS);
|
|
757
|
+
const p1 = rimPoint(a1, RADIUS);
|
|
758
|
+
const largeArc = sweep > 180 ? 1 : 0;
|
|
759
|
+
return `M ${CENTER} ${CENTER} L ${p0.x} ${p0.y} A ${RADIUS} ${RADIUS} 0 ${largeArc} 1 ${p1.x} ${p1.y} Z`;
|
|
760
|
+
};
|
|
761
|
+
/**
|
|
762
|
+
* Rotation, in degrees, that parks the centre of segment `position` under a
|
|
763
|
+
* pointer sitting at `pointerDeg` clockwise from 12 o'clock.
|
|
764
|
+
*/
|
|
765
|
+
const restAngleFor = (position, total, pointerDeg) => pointerDeg - (position + .5) * (360 / total);
|
|
766
|
+
/**
|
|
767
|
+
* Next rotation past `from` that lands on `position`, after at least `turns`
|
|
768
|
+
* more full revolutions. Always forward, so the wheel never visibly rewinds.
|
|
769
|
+
*/
|
|
770
|
+
const landingAngle = (from, position, total, turns, pointerDeg) => {
|
|
771
|
+
const base = from + turns * 360;
|
|
772
|
+
return base + ((restAngleFor(position, total, pointerDeg) - base) % 360 + 360) % 360;
|
|
773
|
+
};
|
|
774
|
+
function RafflePickWheel({ segments, size = 320, colors = DEFAULT_COLORS, labelColor = "#fff", maxLabels = 40, spinDuration = 4200, turns = 4, spinInterval = 1800, pointer = "top", hidePointer = false, className, style, onResult }) {
|
|
775
|
+
const { phase, total, valueAt, valueRef, hasItems, displayed } = useRaffleContext("RafflePick.Wheel");
|
|
776
|
+
const count = segments?.length ?? total;
|
|
777
|
+
const safeCount = Math.max(1, count);
|
|
778
|
+
const labels = (0, react.useMemo)(() => {
|
|
779
|
+
if (segments) return segments;
|
|
780
|
+
return Array.from({ length: safeCount }, (_, i) => String(valueAt(i)));
|
|
781
|
+
}, [
|
|
782
|
+
segments,
|
|
783
|
+
safeCount,
|
|
784
|
+
valueAt
|
|
785
|
+
]);
|
|
786
|
+
const indexOffset = (0, react.useMemo)(() => {
|
|
787
|
+
if (hasItems || segments) return 0;
|
|
788
|
+
const first = valueAt(0);
|
|
789
|
+
return typeof first === "number" ? first : 0;
|
|
790
|
+
}, [
|
|
791
|
+
hasItems,
|
|
792
|
+
segments,
|
|
793
|
+
valueAt
|
|
794
|
+
]);
|
|
795
|
+
const pointerDeg = pointer === "right" ? 90 : 0;
|
|
796
|
+
const wheelRef = (0, react.useRef)(null);
|
|
797
|
+
const rootRef = (0, react.useRef)(null);
|
|
798
|
+
const liveRef = (0, react.useRef)(null);
|
|
799
|
+
const rotationRef = (0, react.useRef)(0);
|
|
800
|
+
const rafRef = (0, react.useRef)(null);
|
|
801
|
+
const lastFrameRef = (0, react.useRef)(0);
|
|
802
|
+
const setResting = (0, react.useCallback)((rest, announce) => {
|
|
803
|
+
const root = rootRef.current;
|
|
804
|
+
if (root) if (rest) root.setAttribute("data-resting", "");
|
|
805
|
+
else root.removeAttribute("data-resting");
|
|
806
|
+
const live = liveRef.current;
|
|
807
|
+
if (live) live.textContent = rest && announce !== void 0 ? announce : "";
|
|
808
|
+
}, []);
|
|
809
|
+
const onResultRef = (0, react.useRef)(onResult);
|
|
810
|
+
(0, react.useEffect)(() => {
|
|
811
|
+
onResultRef.current = onResult;
|
|
812
|
+
});
|
|
813
|
+
const write = (0, react.useCallback)((deg) => {
|
|
814
|
+
const node = wheelRef.current;
|
|
815
|
+
if (node) node.style.transform = `rotate(${deg}deg)`;
|
|
816
|
+
}, []);
|
|
817
|
+
const spinning = phase === "starting" || phase === "running" || phase === "settling";
|
|
818
|
+
(0, react.useEffect)(() => {
|
|
819
|
+
if (!spinning) return;
|
|
820
|
+
setResting(false);
|
|
821
|
+
if (prefersReducedMotion()) return;
|
|
822
|
+
const node = wheelRef.current;
|
|
823
|
+
if (node) node.style.transition = "none";
|
|
824
|
+
lastFrameRef.current = 0;
|
|
825
|
+
const frame = (now) => {
|
|
826
|
+
const last = lastFrameRef.current || now;
|
|
827
|
+
lastFrameRef.current = now;
|
|
828
|
+
rotationRef.current += (now - last) / Math.max(200, spinInterval) * 360;
|
|
829
|
+
write(rotationRef.current);
|
|
830
|
+
rafRef.current = requestAnimationFrame(frame);
|
|
831
|
+
};
|
|
832
|
+
rafRef.current = requestAnimationFrame(frame);
|
|
833
|
+
return () => {
|
|
834
|
+
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
|
|
835
|
+
rafRef.current = null;
|
|
836
|
+
};
|
|
837
|
+
}, [
|
|
838
|
+
spinning,
|
|
839
|
+
spinInterval,
|
|
840
|
+
write,
|
|
841
|
+
setResting
|
|
842
|
+
]);
|
|
843
|
+
(0, react.useEffect)(() => {
|
|
844
|
+
if (phase !== "frozen") return;
|
|
845
|
+
const position = Math.min(safeCount - 1, Math.max(0, valueRef.current - indexOffset));
|
|
846
|
+
const node = wheelRef.current;
|
|
847
|
+
if (prefersReducedMotion() || !node) {
|
|
848
|
+
rotationRef.current = restAngleFor(position, safeCount, pointerDeg);
|
|
849
|
+
write(rotationRef.current);
|
|
850
|
+
setResting(true, String(displayed));
|
|
851
|
+
onResultRef.current?.(displayed);
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
const target = landingAngle(rotationRef.current, position, safeCount, Math.max(0, turns), pointerDeg);
|
|
855
|
+
rotationRef.current = target;
|
|
856
|
+
node.style.transition = `transform ${spinDuration}ms cubic-bezier(0.16, 1, 0.3, 1)`;
|
|
857
|
+
node.getBoundingClientRect();
|
|
858
|
+
write(target);
|
|
859
|
+
let done = false;
|
|
860
|
+
const finish = () => {
|
|
861
|
+
if (done) return;
|
|
862
|
+
done = true;
|
|
863
|
+
setResting(true, String(displayed));
|
|
864
|
+
onResultRef.current?.(displayed);
|
|
865
|
+
};
|
|
866
|
+
node.addEventListener("transitionend", finish, { once: true });
|
|
867
|
+
const fallback = setTimeout(finish, spinDuration + 120);
|
|
868
|
+
return () => {
|
|
869
|
+
node.removeEventListener("transitionend", finish);
|
|
870
|
+
clearTimeout(fallback);
|
|
871
|
+
};
|
|
872
|
+
}, [
|
|
873
|
+
phase,
|
|
874
|
+
safeCount,
|
|
875
|
+
indexOffset,
|
|
876
|
+
turns,
|
|
877
|
+
spinDuration,
|
|
878
|
+
write,
|
|
879
|
+
valueRef,
|
|
880
|
+
displayed,
|
|
881
|
+
pointerDeg,
|
|
882
|
+
setResting
|
|
883
|
+
]);
|
|
884
|
+
(0, react.useEffect)(() => {
|
|
885
|
+
if (phase !== "idle") return;
|
|
886
|
+
const position = Math.min(safeCount - 1, Math.max(0, valueRef.current - indexOffset));
|
|
887
|
+
const node = wheelRef.current;
|
|
888
|
+
if (node) node.style.transition = "none";
|
|
889
|
+
rotationRef.current = restAngleFor(position, safeCount, pointerDeg);
|
|
890
|
+
write(rotationRef.current);
|
|
891
|
+
setResting(true);
|
|
892
|
+
}, [
|
|
893
|
+
phase,
|
|
894
|
+
safeCount,
|
|
895
|
+
indexOffset,
|
|
896
|
+
write,
|
|
897
|
+
valueRef,
|
|
898
|
+
pointerDeg,
|
|
899
|
+
setResting
|
|
900
|
+
]);
|
|
901
|
+
const showLabels = safeCount <= maxLabels;
|
|
902
|
+
const fontSize = Math.max(2.2, Math.min(5, 42 / safeCount + 1.6));
|
|
903
|
+
const paths = (0, react.useMemo)(() => Array.from({ length: safeCount }, (_, i) => ({
|
|
904
|
+
d: safeCount === 1 ? "" : segmentPath(i, safeCount),
|
|
905
|
+
fill: colors[i % colors.length],
|
|
906
|
+
mid: (i + .5) * (360 / safeCount)
|
|
907
|
+
})), [safeCount, colors]);
|
|
908
|
+
const cls = joinClassNames("rrp-wheel", className);
|
|
909
|
+
const pointerStyle = pointer === "right" ? {
|
|
910
|
+
position: "absolute",
|
|
911
|
+
top: "50%",
|
|
912
|
+
right: -2,
|
|
913
|
+
width: 0,
|
|
914
|
+
height: 0,
|
|
915
|
+
transform: "translateY(-50%)",
|
|
916
|
+
borderTop: `${size * .045}px solid transparent`,
|
|
917
|
+
borderBottom: `${size * .045}px solid transparent`,
|
|
918
|
+
borderRight: `${size * .09}px solid currentColor`
|
|
919
|
+
} : {
|
|
920
|
+
position: "absolute",
|
|
921
|
+
left: "50%",
|
|
922
|
+
top: -2,
|
|
923
|
+
width: 0,
|
|
924
|
+
height: 0,
|
|
925
|
+
transform: "translateX(-50%)",
|
|
926
|
+
borderLeft: `${size * .045}px solid transparent`,
|
|
927
|
+
borderRight: `${size * .045}px solid transparent`,
|
|
928
|
+
borderTop: `${size * .09}px solid currentColor`
|
|
929
|
+
};
|
|
930
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
931
|
+
ref: rootRef,
|
|
932
|
+
className: cls,
|
|
933
|
+
style: {
|
|
934
|
+
position: "relative",
|
|
935
|
+
display: "inline-block",
|
|
936
|
+
lineHeight: 0,
|
|
937
|
+
...style,
|
|
938
|
+
width: size,
|
|
939
|
+
height: size
|
|
940
|
+
},
|
|
941
|
+
"data-phase": phase,
|
|
942
|
+
"data-pointer": pointer,
|
|
943
|
+
"data-resting": "",
|
|
944
|
+
children: [
|
|
945
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
|
|
946
|
+
ref: wheelRef,
|
|
947
|
+
className: "rrp-wheel__disc",
|
|
948
|
+
style: {
|
|
949
|
+
display: "block",
|
|
950
|
+
transformOrigin: "50% 50%",
|
|
951
|
+
willChange: "transform"
|
|
952
|
+
},
|
|
953
|
+
viewBox: `0 0 ${VIEWBOX} ${VIEWBOX}`,
|
|
954
|
+
width: size,
|
|
955
|
+
height: size,
|
|
956
|
+
role: "img",
|
|
957
|
+
"aria-label": `Wheel with ${safeCount} segments`,
|
|
958
|
+
children: [safeCount === 1 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
|
|
959
|
+
cx: CENTER,
|
|
960
|
+
cy: CENTER,
|
|
961
|
+
r: RADIUS,
|
|
962
|
+
fill: paths[0].fill
|
|
963
|
+
}) : paths.map((p, i) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
|
|
964
|
+
d: p.d,
|
|
965
|
+
fill: p.fill,
|
|
966
|
+
stroke: "rgba(0,0,0,0.14)",
|
|
967
|
+
strokeWidth: .3
|
|
968
|
+
}, i)), showLabels && paths.map((p, i) => {
|
|
969
|
+
const pt = rimPoint(p.mid, LABEL_RADIUS);
|
|
970
|
+
const rotate = p.mid > 180 ? p.mid + 90 : p.mid - 90;
|
|
971
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("text", {
|
|
972
|
+
x: pt.x,
|
|
973
|
+
y: pt.y,
|
|
974
|
+
fill: labelColor,
|
|
975
|
+
fontSize,
|
|
976
|
+
fontWeight: 600,
|
|
977
|
+
textAnchor: "middle",
|
|
978
|
+
dominantBaseline: "middle",
|
|
979
|
+
transform: `rotate(${rotate} ${pt.x} ${pt.y})`,
|
|
980
|
+
style: {
|
|
981
|
+
pointerEvents: "none",
|
|
982
|
+
userSelect: "none"
|
|
983
|
+
},
|
|
984
|
+
children: labels[i]
|
|
985
|
+
}, i);
|
|
986
|
+
})]
|
|
987
|
+
}),
|
|
988
|
+
!hidePointer && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
989
|
+
className: "rrp-wheel__pointer",
|
|
990
|
+
style: pointerStyle,
|
|
991
|
+
"aria-hidden": "true"
|
|
992
|
+
}),
|
|
993
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
994
|
+
ref: liveRef,
|
|
995
|
+
role: "status",
|
|
996
|
+
"aria-live": "polite",
|
|
997
|
+
style: {
|
|
998
|
+
position: "absolute",
|
|
999
|
+
width: 1,
|
|
1000
|
+
height: 1,
|
|
1001
|
+
overflow: "hidden",
|
|
1002
|
+
clip: "rect(0, 0, 0, 0)",
|
|
1003
|
+
whiteSpace: "nowrap"
|
|
1004
|
+
}
|
|
1005
|
+
})
|
|
1006
|
+
]
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
//#endregion
|
|
721
1010
|
//#region src/components/RafflePick/index.ts
|
|
722
1011
|
const RafflePick = RafflePickRoot;
|
|
723
1012
|
RafflePick.Value = RafflePickValue;
|
|
724
1013
|
RafflePick.Button = RafflePickButton;
|
|
725
1014
|
RafflePick.Countdown = RafflePickCountdown;
|
|
726
1015
|
RafflePick.Slots = RafflePickSlots;
|
|
1016
|
+
RafflePick.Wheel = RafflePickWheel;
|
|
727
1017
|
//#endregion
|
|
728
1018
|
exports.RaffleContext = RaffleContext;
|
|
729
1019
|
exports.RafflePick = RafflePick;
|
|
@@ -731,4 +1021,5 @@ exports.RafflePickButton = RafflePickButton;
|
|
|
731
1021
|
exports.RafflePickCountdown = RafflePickCountdown;
|
|
732
1022
|
exports.RafflePickSlots = RafflePickSlots;
|
|
733
1023
|
exports.RafflePickValue = RafflePickValue;
|
|
1024
|
+
exports.RafflePickWheel = RafflePickWheel;
|
|
734
1025
|
exports.useRaffleContext = useRaffleContext;
|
package/dist/index.d.cts
CHANGED
|
@@ -94,6 +94,51 @@ interface RafflePickCountdownProps {
|
|
|
94
94
|
/** Render-prop for fully custom output. Receives remaining seconds. */
|
|
95
95
|
children?: (remaining: number) => ReactNode;
|
|
96
96
|
}
|
|
97
|
+
interface RafflePickWheelProps {
|
|
98
|
+
/**
|
|
99
|
+
* Segment labels. Defaults to the root's pool — `items`, or the `min`/`max`
|
|
100
|
+
* range. Pass this only to relabel segments; the winner is still chosen by
|
|
101
|
+
* the root, and label `i` maps to pool position `i`.
|
|
102
|
+
*/
|
|
103
|
+
segments?: string[];
|
|
104
|
+
/** Outer diameter in px. Default `320`. */
|
|
105
|
+
size?: number;
|
|
106
|
+
/**
|
|
107
|
+
* Segment fill colors, cycled across segments. Defaults to a built-in
|
|
108
|
+
* 6-color palette. Adjacent segments never share a color as long as the
|
|
109
|
+
* palette length and segment count are not both even multiples.
|
|
110
|
+
*/
|
|
111
|
+
colors?: string[];
|
|
112
|
+
/** Label text color. Default `'#fff'`. */
|
|
113
|
+
labelColor?: string;
|
|
114
|
+
/**
|
|
115
|
+
* Hide labels above this segment count — past it they overlap into noise.
|
|
116
|
+
* Colors and the landing still work. Default `40`. Set `Infinity` to force.
|
|
117
|
+
*/
|
|
118
|
+
maxLabels?: number;
|
|
119
|
+
/** Landing animation duration in ms, from freeze to rest. Default `4200`. */
|
|
120
|
+
spinDuration?: number;
|
|
121
|
+
/** Full turns added before the landing angle, for a longer spin. Default `4`. */
|
|
122
|
+
turns?: number;
|
|
123
|
+
/** Idle/running rotation speed in ms per full turn. Default `1800`. */
|
|
124
|
+
spinInterval?: number;
|
|
125
|
+
/** Pointer position on the rim. Default `'top'`. */
|
|
126
|
+
pointer?: 'top' | 'right';
|
|
127
|
+
/** Hide the built-in pointer triangle to render your own. */
|
|
128
|
+
hidePointer?: boolean;
|
|
129
|
+
className?: string;
|
|
130
|
+
style?: CSSProperties;
|
|
131
|
+
/**
|
|
132
|
+
* Fires when the wheel physically comes to rest, with the winning value.
|
|
133
|
+
*
|
|
134
|
+
* Prefer this over the root's `onSelect` for anything the audience sees —
|
|
135
|
+
* a result banner, confetti, a sound. `onSelect` fires when the value is
|
|
136
|
+
* *committed*, which is the moment the landing animation **starts**, so
|
|
137
|
+
* reacting to it reveals the winner `spinDuration` ms before the wheel gets
|
|
138
|
+
* there.
|
|
139
|
+
*/
|
|
140
|
+
onResult?: (value: RafflePickValue$1) => void;
|
|
141
|
+
}
|
|
97
142
|
//#endregion
|
|
98
143
|
//#region src/components/RafflePick/RafflePick.d.ts
|
|
99
144
|
declare function RafflePickRoot({
|
|
@@ -155,6 +200,23 @@ declare function RafflePickSlots({
|
|
|
155
200
|
onResult
|
|
156
201
|
}: RafflePickSlotsProps): _$react_jsx_runtime0.JSX.Element;
|
|
157
202
|
//#endregion
|
|
203
|
+
//#region src/components/RafflePick/RafflePickWheel.d.ts
|
|
204
|
+
declare function RafflePickWheel({
|
|
205
|
+
segments,
|
|
206
|
+
size,
|
|
207
|
+
colors,
|
|
208
|
+
labelColor,
|
|
209
|
+
maxLabels,
|
|
210
|
+
spinDuration,
|
|
211
|
+
turns,
|
|
212
|
+
spinInterval,
|
|
213
|
+
pointer,
|
|
214
|
+
hidePointer,
|
|
215
|
+
className,
|
|
216
|
+
style,
|
|
217
|
+
onResult
|
|
218
|
+
}: RafflePickWheelProps): _$react_jsx_runtime0.JSX.Element;
|
|
219
|
+
//#endregion
|
|
158
220
|
//#region src/utils/inertia.d.ts
|
|
159
221
|
type RafflePickPhase = 'idle' | 'starting' | 'running' | 'settling' | 'frozen';
|
|
160
222
|
//#endregion
|
|
@@ -175,8 +237,16 @@ interface RaffleContextValue {
|
|
|
175
237
|
exhausted: boolean;
|
|
176
238
|
/** Candidates left to draw. Equals the full pool size when `noRepeat` is off. */
|
|
177
239
|
remaining: number;
|
|
240
|
+
/** Size of the draw pool: item count in items mode, `max - min + 1` in numeric mode. */
|
|
241
|
+
total: number;
|
|
178
242
|
valueRef: RefObject<number>;
|
|
179
243
|
displayValue: (index: number) => RafflePickValue$1;
|
|
244
|
+
/**
|
|
245
|
+
* Pool entry at a 0-based position, regardless of mode. `valueAt(0)` is the
|
|
246
|
+
* first item / `min`. Lets custom UIs enumerate the pool without knowing
|
|
247
|
+
* whether the root runs in items or numeric mode.
|
|
248
|
+
*/
|
|
249
|
+
valueAt: (position: number) => RafflePickValue$1;
|
|
180
250
|
subscribe: (fn: (value: number) => void) => () => void;
|
|
181
251
|
start: () => void;
|
|
182
252
|
freeze: () => void;
|
|
@@ -193,7 +263,8 @@ type RafflePickCompound = typeof RafflePickRoot & {
|
|
|
193
263
|
Button: typeof RafflePickButton;
|
|
194
264
|
Countdown: typeof RafflePickCountdown;
|
|
195
265
|
Slots: typeof RafflePickSlots;
|
|
266
|
+
Wheel: typeof RafflePickWheel;
|
|
196
267
|
};
|
|
197
268
|
declare const RafflePick: RafflePickCompound;
|
|
198
269
|
//#endregion
|
|
199
|
-
export { type AnimationType, RaffleContext, type RaffleContextValue, RafflePick, RafflePickButton, type RafflePickButtonProps, RafflePickCountdown, type RafflePickCountdownProps, type RafflePickPhase, type RafflePickRootProps, RafflePickSlots, type RafflePickSlotsProps, RafflePickValue, type RafflePickValueProps, type RafflePickValue$1 as RafflePickValueType, useRaffleContext };
|
|
270
|
+
export { type AnimationType, RaffleContext, type RaffleContextValue, RafflePick, RafflePickButton, type RafflePickButtonProps, RafflePickCountdown, type RafflePickCountdownProps, type RafflePickPhase, type RafflePickRootProps, RafflePickSlots, type RafflePickSlotsProps, RafflePickValue, type RafflePickValueProps, type RafflePickValue$1 as RafflePickValueType, RafflePickWheel, type RafflePickWheelProps, useRaffleContext };
|
package/dist/index.d.mts
CHANGED
|
@@ -94,6 +94,51 @@ interface RafflePickCountdownProps {
|
|
|
94
94
|
/** Render-prop for fully custom output. Receives remaining seconds. */
|
|
95
95
|
children?: (remaining: number) => ReactNode;
|
|
96
96
|
}
|
|
97
|
+
interface RafflePickWheelProps {
|
|
98
|
+
/**
|
|
99
|
+
* Segment labels. Defaults to the root's pool — `items`, or the `min`/`max`
|
|
100
|
+
* range. Pass this only to relabel segments; the winner is still chosen by
|
|
101
|
+
* the root, and label `i` maps to pool position `i`.
|
|
102
|
+
*/
|
|
103
|
+
segments?: string[];
|
|
104
|
+
/** Outer diameter in px. Default `320`. */
|
|
105
|
+
size?: number;
|
|
106
|
+
/**
|
|
107
|
+
* Segment fill colors, cycled across segments. Defaults to a built-in
|
|
108
|
+
* 6-color palette. Adjacent segments never share a color as long as the
|
|
109
|
+
* palette length and segment count are not both even multiples.
|
|
110
|
+
*/
|
|
111
|
+
colors?: string[];
|
|
112
|
+
/** Label text color. Default `'#fff'`. */
|
|
113
|
+
labelColor?: string;
|
|
114
|
+
/**
|
|
115
|
+
* Hide labels above this segment count — past it they overlap into noise.
|
|
116
|
+
* Colors and the landing still work. Default `40`. Set `Infinity` to force.
|
|
117
|
+
*/
|
|
118
|
+
maxLabels?: number;
|
|
119
|
+
/** Landing animation duration in ms, from freeze to rest. Default `4200`. */
|
|
120
|
+
spinDuration?: number;
|
|
121
|
+
/** Full turns added before the landing angle, for a longer spin. Default `4`. */
|
|
122
|
+
turns?: number;
|
|
123
|
+
/** Idle/running rotation speed in ms per full turn. Default `1800`. */
|
|
124
|
+
spinInterval?: number;
|
|
125
|
+
/** Pointer position on the rim. Default `'top'`. */
|
|
126
|
+
pointer?: 'top' | 'right';
|
|
127
|
+
/** Hide the built-in pointer triangle to render your own. */
|
|
128
|
+
hidePointer?: boolean;
|
|
129
|
+
className?: string;
|
|
130
|
+
style?: CSSProperties;
|
|
131
|
+
/**
|
|
132
|
+
* Fires when the wheel physically comes to rest, with the winning value.
|
|
133
|
+
*
|
|
134
|
+
* Prefer this over the root's `onSelect` for anything the audience sees —
|
|
135
|
+
* a result banner, confetti, a sound. `onSelect` fires when the value is
|
|
136
|
+
* *committed*, which is the moment the landing animation **starts**, so
|
|
137
|
+
* reacting to it reveals the winner `spinDuration` ms before the wheel gets
|
|
138
|
+
* there.
|
|
139
|
+
*/
|
|
140
|
+
onResult?: (value: RafflePickValue$1) => void;
|
|
141
|
+
}
|
|
97
142
|
//#endregion
|
|
98
143
|
//#region src/components/RafflePick/RafflePick.d.ts
|
|
99
144
|
declare function RafflePickRoot({
|
|
@@ -155,6 +200,23 @@ declare function RafflePickSlots({
|
|
|
155
200
|
onResult
|
|
156
201
|
}: RafflePickSlotsProps): _$react_jsx_runtime0.JSX.Element;
|
|
157
202
|
//#endregion
|
|
203
|
+
//#region src/components/RafflePick/RafflePickWheel.d.ts
|
|
204
|
+
declare function RafflePickWheel({
|
|
205
|
+
segments,
|
|
206
|
+
size,
|
|
207
|
+
colors,
|
|
208
|
+
labelColor,
|
|
209
|
+
maxLabels,
|
|
210
|
+
spinDuration,
|
|
211
|
+
turns,
|
|
212
|
+
spinInterval,
|
|
213
|
+
pointer,
|
|
214
|
+
hidePointer,
|
|
215
|
+
className,
|
|
216
|
+
style,
|
|
217
|
+
onResult
|
|
218
|
+
}: RafflePickWheelProps): _$react_jsx_runtime0.JSX.Element;
|
|
219
|
+
//#endregion
|
|
158
220
|
//#region src/utils/inertia.d.ts
|
|
159
221
|
type RafflePickPhase = 'idle' | 'starting' | 'running' | 'settling' | 'frozen';
|
|
160
222
|
//#endregion
|
|
@@ -175,8 +237,16 @@ interface RaffleContextValue {
|
|
|
175
237
|
exhausted: boolean;
|
|
176
238
|
/** Candidates left to draw. Equals the full pool size when `noRepeat` is off. */
|
|
177
239
|
remaining: number;
|
|
240
|
+
/** Size of the draw pool: item count in items mode, `max - min + 1` in numeric mode. */
|
|
241
|
+
total: number;
|
|
178
242
|
valueRef: RefObject<number>;
|
|
179
243
|
displayValue: (index: number) => RafflePickValue$1;
|
|
244
|
+
/**
|
|
245
|
+
* Pool entry at a 0-based position, regardless of mode. `valueAt(0)` is the
|
|
246
|
+
* first item / `min`. Lets custom UIs enumerate the pool without knowing
|
|
247
|
+
* whether the root runs in items or numeric mode.
|
|
248
|
+
*/
|
|
249
|
+
valueAt: (position: number) => RafflePickValue$1;
|
|
180
250
|
subscribe: (fn: (value: number) => void) => () => void;
|
|
181
251
|
start: () => void;
|
|
182
252
|
freeze: () => void;
|
|
@@ -193,7 +263,8 @@ type RafflePickCompound = typeof RafflePickRoot & {
|
|
|
193
263
|
Button: typeof RafflePickButton;
|
|
194
264
|
Countdown: typeof RafflePickCountdown;
|
|
195
265
|
Slots: typeof RafflePickSlots;
|
|
266
|
+
Wheel: typeof RafflePickWheel;
|
|
196
267
|
};
|
|
197
268
|
declare const RafflePick: RafflePickCompound;
|
|
198
269
|
//#endregion
|
|
199
|
-
export { type AnimationType, RaffleContext, type RaffleContextValue, RafflePick, RafflePickButton, type RafflePickButtonProps, RafflePickCountdown, type RafflePickCountdownProps, type RafflePickPhase, type RafflePickRootProps, RafflePickSlots, type RafflePickSlotsProps, RafflePickValue, type RafflePickValueProps, type RafflePickValue$1 as RafflePickValueType, useRaffleContext };
|
|
270
|
+
export { type AnimationType, RaffleContext, type RaffleContextValue, RafflePick, RafflePickButton, type RafflePickButtonProps, RafflePickCountdown, type RafflePickCountdownProps, type RafflePickPhase, type RafflePickRootProps, RafflePickSlots, type RafflePickSlotsProps, RafflePickValue, type RafflePickValueProps, type RafflePickValue$1 as RafflePickValueType, RafflePickWheel, type RafflePickWheelProps, useRaffleContext };
|
package/dist/index.mjs
CHANGED
|
@@ -204,6 +204,11 @@ function RafflePickRoot({ items, min = 1, max = 100, interval = 100, random = tr
|
|
|
204
204
|
const its = itemsRef.current;
|
|
205
205
|
return its && its.length > 0 ? its[index] : index;
|
|
206
206
|
}, []);
|
|
207
|
+
const valueAt = useCallback((position) => {
|
|
208
|
+
const its = itemsRef.current;
|
|
209
|
+
if (its && its.length > 0) return its[position];
|
|
210
|
+
return cycleMin + position;
|
|
211
|
+
}, [cycleMin]);
|
|
207
212
|
const valueToIndex = useCallback((value) => {
|
|
208
213
|
if (value === void 0) return void 0;
|
|
209
214
|
const its = itemsRef.current;
|
|
@@ -319,8 +324,10 @@ function RafflePickRoot({ items, min = 1, max = 100, interval = 100, random = tr
|
|
|
319
324
|
noRepeat,
|
|
320
325
|
exhausted,
|
|
321
326
|
remaining,
|
|
327
|
+
total: totalCandidates,
|
|
322
328
|
valueRef,
|
|
323
329
|
displayValue,
|
|
330
|
+
valueAt,
|
|
324
331
|
subscribe,
|
|
325
332
|
start: guardedStart,
|
|
326
333
|
freeze,
|
|
@@ -339,7 +346,9 @@ function RafflePickRoot({ items, min = 1, max = 100, interval = 100, random = tr
|
|
|
339
346
|
noRepeat,
|
|
340
347
|
exhausted,
|
|
341
348
|
remaining,
|
|
349
|
+
totalCandidates,
|
|
342
350
|
displayValue,
|
|
351
|
+
valueAt,
|
|
343
352
|
subscribe,
|
|
344
353
|
guardedStart,
|
|
345
354
|
freeze,
|
|
@@ -717,11 +726,292 @@ function RafflePickSlots({ length = 3, chars = "0123456789", spinInterval = 80,
|
|
|
717
726
|
});
|
|
718
727
|
}
|
|
719
728
|
//#endregion
|
|
729
|
+
//#region src/components/RafflePick/RafflePickWheel.tsx
|
|
730
|
+
const DEFAULT_COLORS = [
|
|
731
|
+
"#7c1d29",
|
|
732
|
+
"#d4a04a",
|
|
733
|
+
"#2f4858",
|
|
734
|
+
"#a83b2c",
|
|
735
|
+
"#ecc878",
|
|
736
|
+
"#4a0f18"
|
|
737
|
+
];
|
|
738
|
+
const VIEWBOX = 100;
|
|
739
|
+
const CENTER = VIEWBOX / 2;
|
|
740
|
+
const RADIUS = 48;
|
|
741
|
+
const LABEL_RADIUS = RADIUS * .66;
|
|
742
|
+
const prefersReducedMotion = () => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
743
|
+
/** Point on the wheel rim at `deg`, measured clockwise from 12 o'clock. */
|
|
744
|
+
const rimPoint = (deg, radius) => {
|
|
745
|
+
const rad = (deg - 90) * Math.PI / 180;
|
|
746
|
+
return {
|
|
747
|
+
x: CENTER + radius * Math.cos(rad),
|
|
748
|
+
y: CENTER + radius * Math.sin(rad)
|
|
749
|
+
};
|
|
750
|
+
};
|
|
751
|
+
const segmentPath = (index, total) => {
|
|
752
|
+
const sweep = 360 / total;
|
|
753
|
+
const a0 = index * sweep;
|
|
754
|
+
const a1 = a0 + sweep;
|
|
755
|
+
const p0 = rimPoint(a0, RADIUS);
|
|
756
|
+
const p1 = rimPoint(a1, RADIUS);
|
|
757
|
+
const largeArc = sweep > 180 ? 1 : 0;
|
|
758
|
+
return `M ${CENTER} ${CENTER} L ${p0.x} ${p0.y} A ${RADIUS} ${RADIUS} 0 ${largeArc} 1 ${p1.x} ${p1.y} Z`;
|
|
759
|
+
};
|
|
760
|
+
/**
|
|
761
|
+
* Rotation, in degrees, that parks the centre of segment `position` under a
|
|
762
|
+
* pointer sitting at `pointerDeg` clockwise from 12 o'clock.
|
|
763
|
+
*/
|
|
764
|
+
const restAngleFor = (position, total, pointerDeg) => pointerDeg - (position + .5) * (360 / total);
|
|
765
|
+
/**
|
|
766
|
+
* Next rotation past `from` that lands on `position`, after at least `turns`
|
|
767
|
+
* more full revolutions. Always forward, so the wheel never visibly rewinds.
|
|
768
|
+
*/
|
|
769
|
+
const landingAngle = (from, position, total, turns, pointerDeg) => {
|
|
770
|
+
const base = from + turns * 360;
|
|
771
|
+
return base + ((restAngleFor(position, total, pointerDeg) - base) % 360 + 360) % 360;
|
|
772
|
+
};
|
|
773
|
+
function RafflePickWheel({ segments, size = 320, colors = DEFAULT_COLORS, labelColor = "#fff", maxLabels = 40, spinDuration = 4200, turns = 4, spinInterval = 1800, pointer = "top", hidePointer = false, className, style, onResult }) {
|
|
774
|
+
const { phase, total, valueAt, valueRef, hasItems, displayed } = useRaffleContext("RafflePick.Wheel");
|
|
775
|
+
const count = segments?.length ?? total;
|
|
776
|
+
const safeCount = Math.max(1, count);
|
|
777
|
+
const labels = useMemo(() => {
|
|
778
|
+
if (segments) return segments;
|
|
779
|
+
return Array.from({ length: safeCount }, (_, i) => String(valueAt(i)));
|
|
780
|
+
}, [
|
|
781
|
+
segments,
|
|
782
|
+
safeCount,
|
|
783
|
+
valueAt
|
|
784
|
+
]);
|
|
785
|
+
const indexOffset = useMemo(() => {
|
|
786
|
+
if (hasItems || segments) return 0;
|
|
787
|
+
const first = valueAt(0);
|
|
788
|
+
return typeof first === "number" ? first : 0;
|
|
789
|
+
}, [
|
|
790
|
+
hasItems,
|
|
791
|
+
segments,
|
|
792
|
+
valueAt
|
|
793
|
+
]);
|
|
794
|
+
const pointerDeg = pointer === "right" ? 90 : 0;
|
|
795
|
+
const wheelRef = useRef(null);
|
|
796
|
+
const rootRef = useRef(null);
|
|
797
|
+
const liveRef = useRef(null);
|
|
798
|
+
const rotationRef = useRef(0);
|
|
799
|
+
const rafRef = useRef(null);
|
|
800
|
+
const lastFrameRef = useRef(0);
|
|
801
|
+
const setResting = useCallback((rest, announce) => {
|
|
802
|
+
const root = rootRef.current;
|
|
803
|
+
if (root) if (rest) root.setAttribute("data-resting", "");
|
|
804
|
+
else root.removeAttribute("data-resting");
|
|
805
|
+
const live = liveRef.current;
|
|
806
|
+
if (live) live.textContent = rest && announce !== void 0 ? announce : "";
|
|
807
|
+
}, []);
|
|
808
|
+
const onResultRef = useRef(onResult);
|
|
809
|
+
useEffect(() => {
|
|
810
|
+
onResultRef.current = onResult;
|
|
811
|
+
});
|
|
812
|
+
const write = useCallback((deg) => {
|
|
813
|
+
const node = wheelRef.current;
|
|
814
|
+
if (node) node.style.transform = `rotate(${deg}deg)`;
|
|
815
|
+
}, []);
|
|
816
|
+
const spinning = phase === "starting" || phase === "running" || phase === "settling";
|
|
817
|
+
useEffect(() => {
|
|
818
|
+
if (!spinning) return;
|
|
819
|
+
setResting(false);
|
|
820
|
+
if (prefersReducedMotion()) return;
|
|
821
|
+
const node = wheelRef.current;
|
|
822
|
+
if (node) node.style.transition = "none";
|
|
823
|
+
lastFrameRef.current = 0;
|
|
824
|
+
const frame = (now) => {
|
|
825
|
+
const last = lastFrameRef.current || now;
|
|
826
|
+
lastFrameRef.current = now;
|
|
827
|
+
rotationRef.current += (now - last) / Math.max(200, spinInterval) * 360;
|
|
828
|
+
write(rotationRef.current);
|
|
829
|
+
rafRef.current = requestAnimationFrame(frame);
|
|
830
|
+
};
|
|
831
|
+
rafRef.current = requestAnimationFrame(frame);
|
|
832
|
+
return () => {
|
|
833
|
+
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
|
|
834
|
+
rafRef.current = null;
|
|
835
|
+
};
|
|
836
|
+
}, [
|
|
837
|
+
spinning,
|
|
838
|
+
spinInterval,
|
|
839
|
+
write,
|
|
840
|
+
setResting
|
|
841
|
+
]);
|
|
842
|
+
useEffect(() => {
|
|
843
|
+
if (phase !== "frozen") return;
|
|
844
|
+
const position = Math.min(safeCount - 1, Math.max(0, valueRef.current - indexOffset));
|
|
845
|
+
const node = wheelRef.current;
|
|
846
|
+
if (prefersReducedMotion() || !node) {
|
|
847
|
+
rotationRef.current = restAngleFor(position, safeCount, pointerDeg);
|
|
848
|
+
write(rotationRef.current);
|
|
849
|
+
setResting(true, String(displayed));
|
|
850
|
+
onResultRef.current?.(displayed);
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
const target = landingAngle(rotationRef.current, position, safeCount, Math.max(0, turns), pointerDeg);
|
|
854
|
+
rotationRef.current = target;
|
|
855
|
+
node.style.transition = `transform ${spinDuration}ms cubic-bezier(0.16, 1, 0.3, 1)`;
|
|
856
|
+
node.getBoundingClientRect();
|
|
857
|
+
write(target);
|
|
858
|
+
let done = false;
|
|
859
|
+
const finish = () => {
|
|
860
|
+
if (done) return;
|
|
861
|
+
done = true;
|
|
862
|
+
setResting(true, String(displayed));
|
|
863
|
+
onResultRef.current?.(displayed);
|
|
864
|
+
};
|
|
865
|
+
node.addEventListener("transitionend", finish, { once: true });
|
|
866
|
+
const fallback = setTimeout(finish, spinDuration + 120);
|
|
867
|
+
return () => {
|
|
868
|
+
node.removeEventListener("transitionend", finish);
|
|
869
|
+
clearTimeout(fallback);
|
|
870
|
+
};
|
|
871
|
+
}, [
|
|
872
|
+
phase,
|
|
873
|
+
safeCount,
|
|
874
|
+
indexOffset,
|
|
875
|
+
turns,
|
|
876
|
+
spinDuration,
|
|
877
|
+
write,
|
|
878
|
+
valueRef,
|
|
879
|
+
displayed,
|
|
880
|
+
pointerDeg,
|
|
881
|
+
setResting
|
|
882
|
+
]);
|
|
883
|
+
useEffect(() => {
|
|
884
|
+
if (phase !== "idle") return;
|
|
885
|
+
const position = Math.min(safeCount - 1, Math.max(0, valueRef.current - indexOffset));
|
|
886
|
+
const node = wheelRef.current;
|
|
887
|
+
if (node) node.style.transition = "none";
|
|
888
|
+
rotationRef.current = restAngleFor(position, safeCount, pointerDeg);
|
|
889
|
+
write(rotationRef.current);
|
|
890
|
+
setResting(true);
|
|
891
|
+
}, [
|
|
892
|
+
phase,
|
|
893
|
+
safeCount,
|
|
894
|
+
indexOffset,
|
|
895
|
+
write,
|
|
896
|
+
valueRef,
|
|
897
|
+
pointerDeg,
|
|
898
|
+
setResting
|
|
899
|
+
]);
|
|
900
|
+
const showLabels = safeCount <= maxLabels;
|
|
901
|
+
const fontSize = Math.max(2.2, Math.min(5, 42 / safeCount + 1.6));
|
|
902
|
+
const paths = useMemo(() => Array.from({ length: safeCount }, (_, i) => ({
|
|
903
|
+
d: safeCount === 1 ? "" : segmentPath(i, safeCount),
|
|
904
|
+
fill: colors[i % colors.length],
|
|
905
|
+
mid: (i + .5) * (360 / safeCount)
|
|
906
|
+
})), [safeCount, colors]);
|
|
907
|
+
const cls = joinClassNames("rrp-wheel", className);
|
|
908
|
+
const pointerStyle = pointer === "right" ? {
|
|
909
|
+
position: "absolute",
|
|
910
|
+
top: "50%",
|
|
911
|
+
right: -2,
|
|
912
|
+
width: 0,
|
|
913
|
+
height: 0,
|
|
914
|
+
transform: "translateY(-50%)",
|
|
915
|
+
borderTop: `${size * .045}px solid transparent`,
|
|
916
|
+
borderBottom: `${size * .045}px solid transparent`,
|
|
917
|
+
borderRight: `${size * .09}px solid currentColor`
|
|
918
|
+
} : {
|
|
919
|
+
position: "absolute",
|
|
920
|
+
left: "50%",
|
|
921
|
+
top: -2,
|
|
922
|
+
width: 0,
|
|
923
|
+
height: 0,
|
|
924
|
+
transform: "translateX(-50%)",
|
|
925
|
+
borderLeft: `${size * .045}px solid transparent`,
|
|
926
|
+
borderRight: `${size * .045}px solid transparent`,
|
|
927
|
+
borderTop: `${size * .09}px solid currentColor`
|
|
928
|
+
};
|
|
929
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
930
|
+
ref: rootRef,
|
|
931
|
+
className: cls,
|
|
932
|
+
style: {
|
|
933
|
+
position: "relative",
|
|
934
|
+
display: "inline-block",
|
|
935
|
+
lineHeight: 0,
|
|
936
|
+
...style,
|
|
937
|
+
width: size,
|
|
938
|
+
height: size
|
|
939
|
+
},
|
|
940
|
+
"data-phase": phase,
|
|
941
|
+
"data-pointer": pointer,
|
|
942
|
+
"data-resting": "",
|
|
943
|
+
children: [
|
|
944
|
+
/* @__PURE__ */ jsxs("svg", {
|
|
945
|
+
ref: wheelRef,
|
|
946
|
+
className: "rrp-wheel__disc",
|
|
947
|
+
style: {
|
|
948
|
+
display: "block",
|
|
949
|
+
transformOrigin: "50% 50%",
|
|
950
|
+
willChange: "transform"
|
|
951
|
+
},
|
|
952
|
+
viewBox: `0 0 ${VIEWBOX} ${VIEWBOX}`,
|
|
953
|
+
width: size,
|
|
954
|
+
height: size,
|
|
955
|
+
role: "img",
|
|
956
|
+
"aria-label": `Wheel with ${safeCount} segments`,
|
|
957
|
+
children: [safeCount === 1 ? /* @__PURE__ */ jsx("circle", {
|
|
958
|
+
cx: CENTER,
|
|
959
|
+
cy: CENTER,
|
|
960
|
+
r: RADIUS,
|
|
961
|
+
fill: paths[0].fill
|
|
962
|
+
}) : paths.map((p, i) => /* @__PURE__ */ jsx("path", {
|
|
963
|
+
d: p.d,
|
|
964
|
+
fill: p.fill,
|
|
965
|
+
stroke: "rgba(0,0,0,0.14)",
|
|
966
|
+
strokeWidth: .3
|
|
967
|
+
}, i)), showLabels && paths.map((p, i) => {
|
|
968
|
+
const pt = rimPoint(p.mid, LABEL_RADIUS);
|
|
969
|
+
const rotate = p.mid > 180 ? p.mid + 90 : p.mid - 90;
|
|
970
|
+
return /* @__PURE__ */ jsx("text", {
|
|
971
|
+
x: pt.x,
|
|
972
|
+
y: pt.y,
|
|
973
|
+
fill: labelColor,
|
|
974
|
+
fontSize,
|
|
975
|
+
fontWeight: 600,
|
|
976
|
+
textAnchor: "middle",
|
|
977
|
+
dominantBaseline: "middle",
|
|
978
|
+
transform: `rotate(${rotate} ${pt.x} ${pt.y})`,
|
|
979
|
+
style: {
|
|
980
|
+
pointerEvents: "none",
|
|
981
|
+
userSelect: "none"
|
|
982
|
+
},
|
|
983
|
+
children: labels[i]
|
|
984
|
+
}, i);
|
|
985
|
+
})]
|
|
986
|
+
}),
|
|
987
|
+
!hidePointer && /* @__PURE__ */ jsx("span", {
|
|
988
|
+
className: "rrp-wheel__pointer",
|
|
989
|
+
style: pointerStyle,
|
|
990
|
+
"aria-hidden": "true"
|
|
991
|
+
}),
|
|
992
|
+
/* @__PURE__ */ jsx("span", {
|
|
993
|
+
ref: liveRef,
|
|
994
|
+
role: "status",
|
|
995
|
+
"aria-live": "polite",
|
|
996
|
+
style: {
|
|
997
|
+
position: "absolute",
|
|
998
|
+
width: 1,
|
|
999
|
+
height: 1,
|
|
1000
|
+
overflow: "hidden",
|
|
1001
|
+
clip: "rect(0, 0, 0, 0)",
|
|
1002
|
+
whiteSpace: "nowrap"
|
|
1003
|
+
}
|
|
1004
|
+
})
|
|
1005
|
+
]
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
//#endregion
|
|
720
1009
|
//#region src/components/RafflePick/index.ts
|
|
721
1010
|
const RafflePick = RafflePickRoot;
|
|
722
1011
|
RafflePick.Value = RafflePickValue;
|
|
723
1012
|
RafflePick.Button = RafflePickButton;
|
|
724
1013
|
RafflePick.Countdown = RafflePickCountdown;
|
|
725
1014
|
RafflePick.Slots = RafflePickSlots;
|
|
1015
|
+
RafflePick.Wheel = RafflePickWheel;
|
|
726
1016
|
//#endregion
|
|
727
|
-
export { RaffleContext, RafflePick, RafflePickButton, RafflePickCountdown, RafflePickSlots, RafflePickValue, useRaffleContext };
|
|
1017
|
+
export { RaffleContext, RafflePick, RafflePickButton, RafflePickCountdown, RafflePickSlots, RafflePickValue, RafflePickWheel, useRaffleContext };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-raffle-picker",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "React random picker: draw a random winner or number with slot-machine, reel and countdown animations. Headless, typed, zero-dependency.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"access": "public",
|
|
@@ -61,18 +61,28 @@
|
|
|
61
61
|
},
|
|
62
62
|
"keywords": [
|
|
63
63
|
"react",
|
|
64
|
-
"raffle",
|
|
65
|
-
"giveaway",
|
|
66
|
-
"winner",
|
|
67
|
-
"picker",
|
|
68
64
|
"random-picker",
|
|
69
|
-
"
|
|
65
|
+
"random-name-picker",
|
|
66
|
+
"name-picker",
|
|
67
|
+
"winner-picker",
|
|
68
|
+
"random-winner",
|
|
69
|
+
"giveaway",
|
|
70
|
+
"giveaway-picker",
|
|
71
|
+
"prize-draw",
|
|
72
|
+
"raffle",
|
|
73
|
+
"raffle-picker",
|
|
70
74
|
"lottery",
|
|
71
75
|
"slot-machine",
|
|
76
|
+
"slot-machine-react",
|
|
77
|
+
"reel",
|
|
78
|
+
"number-picker",
|
|
79
|
+
"random-number",
|
|
72
80
|
"countdown",
|
|
73
81
|
"headless",
|
|
82
|
+
"headless-ui",
|
|
74
83
|
"compound-components",
|
|
75
|
-
"react-component"
|
|
84
|
+
"react-component",
|
|
85
|
+
"typescript"
|
|
76
86
|
],
|
|
77
87
|
"author": "Kirilinsky (https://github.com/kirilinsky)",
|
|
78
88
|
"license": "MIT",
|
|
@@ -115,4 +125,4 @@
|
|
|
115
125
|
"volta": {
|
|
116
126
|
"node": "22.12.0"
|
|
117
127
|
}
|
|
118
|
-
}
|
|
128
|
+
}
|