react-raffle-picker 0.3.2 → 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 CHANGED
@@ -180,6 +180,36 @@ Independent multi-reel slot machine. Each reel ticks on its own and stops with a
180
180
  | `onResult` | `(joined: string) => void` | — | Fires when the last reel lands. |
181
181
  | `className`, `slotClassName`, `style`, `slotStyle` | various | — | Style hooks. |
182
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
+
183
213
  ## Recipes
184
214
 
185
215
  ### Inline chip in a sentence
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,6 +1,6 @@
1
1
  {
2
2
  "name": "react-raffle-picker",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
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": {