react-raffle-picker 0.1.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/LICENSE +21 -0
- package/README.md +200 -0
- package/dist/index.cjs +549 -0
- package/dist/index.d.cts +151 -0
- package/dist/index.d.mts +151 -0
- package/dist/index.mjs +542 -0
- package/package.json +110 -0
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import * as _$react from "react";
|
|
2
|
+
import { CSSProperties, ElementType, ReactNode, RefObject } from "react";
|
|
3
|
+
import * as _$react_jsx_runtime0 from "react/jsx-runtime";
|
|
4
|
+
|
|
5
|
+
//#region src/types.d.ts
|
|
6
|
+
type AnimationType = 'roll' | 'fade' | 'blur' | 'reel';
|
|
7
|
+
type RafflePickValue$1 = number | string;
|
|
8
|
+
interface RafflePickRootProps {
|
|
9
|
+
items?: string[];
|
|
10
|
+
min?: number;
|
|
11
|
+
max?: number;
|
|
12
|
+
interval?: number;
|
|
13
|
+
random?: boolean;
|
|
14
|
+
inertia?: boolean;
|
|
15
|
+
autoStart?: boolean;
|
|
16
|
+
onSelect?: (value: RafflePickValue$1) => void;
|
|
17
|
+
as?: ElementType;
|
|
18
|
+
className?: string;
|
|
19
|
+
style?: CSSProperties;
|
|
20
|
+
children?: ReactNode;
|
|
21
|
+
}
|
|
22
|
+
interface RafflePickValueProps {
|
|
23
|
+
animation?: AnimationType;
|
|
24
|
+
className?: string;
|
|
25
|
+
style?: CSSProperties;
|
|
26
|
+
as?: ElementType;
|
|
27
|
+
}
|
|
28
|
+
interface RafflePickButtonProps {
|
|
29
|
+
className?: string;
|
|
30
|
+
style?: CSSProperties;
|
|
31
|
+
/** Fallback label across all states. */
|
|
32
|
+
children?: ReactNode;
|
|
33
|
+
/** Label for idle / frozen (click starts a round). */
|
|
34
|
+
startLabel?: ReactNode;
|
|
35
|
+
/** Label while running (click stops). */
|
|
36
|
+
stopLabel?: ReactNode;
|
|
37
|
+
/** Label while settling (button disabled). */
|
|
38
|
+
waitLabel?: ReactNode;
|
|
39
|
+
}
|
|
40
|
+
interface RafflePickSlotsProps {
|
|
41
|
+
/** Number of slots. */
|
|
42
|
+
length?: number;
|
|
43
|
+
/** Character pool each slot picks from. */
|
|
44
|
+
chars?: string;
|
|
45
|
+
/** Tick interval per slot, ms. Clamped ≥ 50. */
|
|
46
|
+
spinInterval?: number;
|
|
47
|
+
/** Delay between consecutive slot stops on settle, ms. */
|
|
48
|
+
staggerMs?: number;
|
|
49
|
+
className?: string;
|
|
50
|
+
slotClassName?: string;
|
|
51
|
+
style?: CSSProperties;
|
|
52
|
+
slotStyle?: CSSProperties;
|
|
53
|
+
/** Fires when last slot stops with joined result. */
|
|
54
|
+
onResult?: (result: string) => void;
|
|
55
|
+
}
|
|
56
|
+
interface RafflePickCountdownProps {
|
|
57
|
+
/** Seconds before auto-freeze. Required. */
|
|
58
|
+
seconds: number;
|
|
59
|
+
className?: string;
|
|
60
|
+
style?: CSSProperties;
|
|
61
|
+
/** Render-prop for fully custom output. Receives remaining seconds. */
|
|
62
|
+
children?: (remaining: number) => ReactNode;
|
|
63
|
+
}
|
|
64
|
+
//#endregion
|
|
65
|
+
//#region src/components/RafflePick/RafflePick.d.ts
|
|
66
|
+
declare function RafflePickRoot({
|
|
67
|
+
items,
|
|
68
|
+
min,
|
|
69
|
+
max,
|
|
70
|
+
interval,
|
|
71
|
+
random,
|
|
72
|
+
inertia,
|
|
73
|
+
autoStart,
|
|
74
|
+
onSelect,
|
|
75
|
+
as,
|
|
76
|
+
className,
|
|
77
|
+
style,
|
|
78
|
+
children
|
|
79
|
+
}: RafflePickRootProps): _$react.ReactElement<any, string | _$react.JSXElementConstructor<any>>;
|
|
80
|
+
//#endregion
|
|
81
|
+
//#region src/components/RafflePick/RafflePickValue.d.ts
|
|
82
|
+
declare function RafflePickValue({
|
|
83
|
+
animation,
|
|
84
|
+
className,
|
|
85
|
+
style,
|
|
86
|
+
as
|
|
87
|
+
}: RafflePickValueProps): _$react_jsx_runtime0.JSX.Element;
|
|
88
|
+
//#endregion
|
|
89
|
+
//#region src/components/RafflePick/RafflePickButton.d.ts
|
|
90
|
+
declare function RafflePickButton({
|
|
91
|
+
className,
|
|
92
|
+
style,
|
|
93
|
+
children,
|
|
94
|
+
startLabel,
|
|
95
|
+
stopLabel,
|
|
96
|
+
waitLabel
|
|
97
|
+
}: RafflePickButtonProps): _$react_jsx_runtime0.JSX.Element;
|
|
98
|
+
//#endregion
|
|
99
|
+
//#region src/components/RafflePick/RafflePickCountdown.d.ts
|
|
100
|
+
declare function RafflePickCountdown({
|
|
101
|
+
seconds,
|
|
102
|
+
className,
|
|
103
|
+
style,
|
|
104
|
+
children
|
|
105
|
+
}: RafflePickCountdownProps): _$react_jsx_runtime0.JSX.Element | null;
|
|
106
|
+
//#endregion
|
|
107
|
+
//#region src/components/RafflePick/RafflePickSlots.d.ts
|
|
108
|
+
declare function RafflePickSlots({
|
|
109
|
+
length,
|
|
110
|
+
chars,
|
|
111
|
+
spinInterval,
|
|
112
|
+
staggerMs,
|
|
113
|
+
className,
|
|
114
|
+
slotClassName,
|
|
115
|
+
style,
|
|
116
|
+
slotStyle,
|
|
117
|
+
onResult
|
|
118
|
+
}: RafflePickSlotsProps): _$react_jsx_runtime0.JSX.Element;
|
|
119
|
+
//#endregion
|
|
120
|
+
//#region src/utils/inertia.d.ts
|
|
121
|
+
type RafflePickPhase = 'idle' | 'starting' | 'running' | 'settling' | 'frozen';
|
|
122
|
+
//#endregion
|
|
123
|
+
//#region src/components/RafflePick/context.d.ts
|
|
124
|
+
interface RaffleContextValue {
|
|
125
|
+
phase: RafflePickPhase;
|
|
126
|
+
step: number;
|
|
127
|
+
displayed: RafflePickValue$1;
|
|
128
|
+
cycleInterval: number;
|
|
129
|
+
inertia: boolean;
|
|
130
|
+
hasItems: boolean;
|
|
131
|
+
initialIndex: number;
|
|
132
|
+
valueRef: RefObject<number>;
|
|
133
|
+
displayValue: (index: number) => RafflePickValue$1;
|
|
134
|
+
subscribe: (fn: (value: number) => void) => () => void;
|
|
135
|
+
start: () => void;
|
|
136
|
+
freeze: () => void;
|
|
137
|
+
reset: () => void;
|
|
138
|
+
}
|
|
139
|
+
declare const RaffleContext: _$react.Context<RaffleContextValue | null>;
|
|
140
|
+
declare const useRaffleContext: (componentName: string) => RaffleContextValue;
|
|
141
|
+
//#endregion
|
|
142
|
+
//#region src/components/RafflePick/index.d.ts
|
|
143
|
+
type RafflePickCompound = typeof RafflePickRoot & {
|
|
144
|
+
Value: typeof RafflePickValue;
|
|
145
|
+
Button: typeof RafflePickButton;
|
|
146
|
+
Countdown: typeof RafflePickCountdown;
|
|
147
|
+
Slots: typeof RafflePickSlots;
|
|
148
|
+
};
|
|
149
|
+
declare const RafflePick: RafflePickCompound;
|
|
150
|
+
//#endregion
|
|
151
|
+
export { type AnimationType, RaffleContext, RafflePick, RafflePickButton, type RafflePickButtonProps, RafflePickCountdown, type RafflePickCountdownProps, type RafflePickRootProps, RafflePickSlots, type RafflePickSlotsProps, RafflePickValue, type RafflePickValueProps, type RafflePickValue$1 as RafflePickValueType, useRaffleContext };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
import { createContext, createElement, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState } from "react";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
//#region src/utils/get-random.ts
|
|
4
|
+
const getRandom = (min, max) => {
|
|
5
|
+
min = Math.ceil(min);
|
|
6
|
+
max = Math.floor(max);
|
|
7
|
+
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
8
|
+
};
|
|
9
|
+
//#endregion
|
|
10
|
+
//#region src/hooks/useNumberCycle.ts
|
|
11
|
+
const useNumberCycle = ({ min, max, interval, random, running, valueRef, onTick }) => {
|
|
12
|
+
const onTickRef = useRef(onTick);
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
onTickRef.current = onTick;
|
|
15
|
+
});
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (!running) return;
|
|
18
|
+
const id = setInterval(() => {
|
|
19
|
+
const cur = valueRef.current;
|
|
20
|
+
const next = random ? getRandom(min, max) : cur >= max ? min : cur + 1;
|
|
21
|
+
valueRef.current = next;
|
|
22
|
+
onTickRef.current?.(next);
|
|
23
|
+
}, interval);
|
|
24
|
+
return () => clearInterval(id);
|
|
25
|
+
}, [
|
|
26
|
+
running,
|
|
27
|
+
interval,
|
|
28
|
+
min,
|
|
29
|
+
max,
|
|
30
|
+
random,
|
|
31
|
+
valueRef
|
|
32
|
+
]);
|
|
33
|
+
return valueRef;
|
|
34
|
+
};
|
|
35
|
+
//#endregion
|
|
36
|
+
//#region src/utils/inertia.ts
|
|
37
|
+
const STOP_INERTIA_MS = 1080;
|
|
38
|
+
const START_INTERVAL_MULTIPLIERS = [
|
|
39
|
+
2.4,
|
|
40
|
+
1.55,
|
|
41
|
+
1
|
|
42
|
+
];
|
|
43
|
+
const STOP_INTERVAL_MULTIPLIERS = [
|
|
44
|
+
1.15,
|
|
45
|
+
1.65,
|
|
46
|
+
2.35,
|
|
47
|
+
3.25
|
|
48
|
+
];
|
|
49
|
+
const START_INERTIA_STEPS = [{
|
|
50
|
+
delay: 140,
|
|
51
|
+
step: 1
|
|
52
|
+
}, {
|
|
53
|
+
delay: 300,
|
|
54
|
+
step: 2
|
|
55
|
+
}];
|
|
56
|
+
const STOP_INERTIA_STEPS = [
|
|
57
|
+
{
|
|
58
|
+
delay: 180,
|
|
59
|
+
step: 1
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
delay: 460,
|
|
63
|
+
step: 2
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
delay: 760,
|
|
67
|
+
step: 3
|
|
68
|
+
}
|
|
69
|
+
];
|
|
70
|
+
const getInertiaMultiplier = (phase, step, inertia) => {
|
|
71
|
+
if (!inertia) return 1;
|
|
72
|
+
if (phase === "starting") return START_INTERVAL_MULTIPLIERS[step] ?? 1;
|
|
73
|
+
if (phase === "settling") return STOP_INTERVAL_MULTIPLIERS[step] ?? STOP_INTERVAL_MULTIPLIERS[STOP_INTERVAL_MULTIPLIERS.length - 1];
|
|
74
|
+
return 1;
|
|
75
|
+
};
|
|
76
|
+
//#endregion
|
|
77
|
+
//#region src/hooks/useRafflePhase.ts
|
|
78
|
+
const reducer = (s, a) => a.type === "phase" ? {
|
|
79
|
+
phase: a.phase,
|
|
80
|
+
step: 0
|
|
81
|
+
} : {
|
|
82
|
+
phase: s.phase,
|
|
83
|
+
step: a.step
|
|
84
|
+
};
|
|
85
|
+
function useRafflePhase(inertia, initialPhase, onSettle) {
|
|
86
|
+
const [state, dispatch] = useReducer(reducer, {
|
|
87
|
+
phase: initialPhase,
|
|
88
|
+
step: 0
|
|
89
|
+
});
|
|
90
|
+
const timersRef = useRef([]);
|
|
91
|
+
const onSettleRef = useRef(onSettle);
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
onSettleRef.current = onSettle;
|
|
94
|
+
});
|
|
95
|
+
const clear = useCallback(() => {
|
|
96
|
+
const timers = timersRef.current;
|
|
97
|
+
for (let i = 0; i < timers.length; i++) clearTimeout(timers[i]);
|
|
98
|
+
timersRef.current = [];
|
|
99
|
+
}, []);
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (state.phase === "starting") {
|
|
102
|
+
for (const { delay, step } of START_INERTIA_STEPS) timersRef.current.push(setTimeout(() => dispatch({
|
|
103
|
+
type: "step",
|
|
104
|
+
step
|
|
105
|
+
}), delay));
|
|
106
|
+
timersRef.current.push(setTimeout(() => dispatch({
|
|
107
|
+
type: "phase",
|
|
108
|
+
phase: "running"
|
|
109
|
+
}), 460));
|
|
110
|
+
} else if (state.phase === "settling") {
|
|
111
|
+
for (const { delay, step } of STOP_INERTIA_STEPS) timersRef.current.push(setTimeout(() => dispatch({
|
|
112
|
+
type: "step",
|
|
113
|
+
step
|
|
114
|
+
}), delay));
|
|
115
|
+
timersRef.current.push(setTimeout(() => {
|
|
116
|
+
onSettleRef.current();
|
|
117
|
+
dispatch({
|
|
118
|
+
type: "phase",
|
|
119
|
+
phase: "frozen"
|
|
120
|
+
});
|
|
121
|
+
}, STOP_INERTIA_MS));
|
|
122
|
+
}
|
|
123
|
+
return clear;
|
|
124
|
+
}, [state.phase, clear]);
|
|
125
|
+
const start = useCallback(() => {
|
|
126
|
+
clear();
|
|
127
|
+
dispatch({
|
|
128
|
+
type: "phase",
|
|
129
|
+
phase: inertia ? "starting" : "running"
|
|
130
|
+
});
|
|
131
|
+
}, [clear, inertia]);
|
|
132
|
+
const freeze = useCallback(() => {
|
|
133
|
+
clear();
|
|
134
|
+
if (inertia) dispatch({
|
|
135
|
+
type: "phase",
|
|
136
|
+
phase: "settling"
|
|
137
|
+
});
|
|
138
|
+
else {
|
|
139
|
+
onSettleRef.current();
|
|
140
|
+
dispatch({
|
|
141
|
+
type: "phase",
|
|
142
|
+
phase: "frozen"
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}, [clear, inertia]);
|
|
146
|
+
const reset = useCallback(() => {
|
|
147
|
+
clear();
|
|
148
|
+
dispatch({
|
|
149
|
+
type: "phase",
|
|
150
|
+
phase: "idle"
|
|
151
|
+
});
|
|
152
|
+
}, [clear]);
|
|
153
|
+
return {
|
|
154
|
+
phase: state.phase,
|
|
155
|
+
step: state.step,
|
|
156
|
+
start,
|
|
157
|
+
freeze,
|
|
158
|
+
reset
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
//#endregion
|
|
162
|
+
//#region src/utils/class-names.ts
|
|
163
|
+
const joinClassNames = (...classNames) => classNames.filter(Boolean).join(" ");
|
|
164
|
+
//#endregion
|
|
165
|
+
//#region src/components/RafflePick/context.ts
|
|
166
|
+
const RaffleContext = createContext(null);
|
|
167
|
+
const useRaffleContext = (componentName) => {
|
|
168
|
+
const ctx = useContext(RaffleContext);
|
|
169
|
+
if (!ctx) throw new Error(`<${componentName}> must be rendered inside <RafflePick>.`);
|
|
170
|
+
return ctx;
|
|
171
|
+
};
|
|
172
|
+
//#endregion
|
|
173
|
+
//#region src/components/RafflePick/RafflePick.tsx
|
|
174
|
+
function RafflePickRoot({ items, min = 1, max = 100, interval = 100, random = true, inertia = false, autoStart = true, onSelect, as = "div", className, style, children }) {
|
|
175
|
+
const itemCount = items?.length ?? 0;
|
|
176
|
+
const hasItems = itemCount > 0;
|
|
177
|
+
const cycleMin = hasItems ? 0 : min;
|
|
178
|
+
const cycleMax = hasItems ? itemCount - 1 : max;
|
|
179
|
+
const initialPhase = autoStart ? inertia ? "starting" : "running" : "idle";
|
|
180
|
+
const itemsRef = useRef(items);
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
itemsRef.current = items;
|
|
183
|
+
}, [items]);
|
|
184
|
+
const displayValue = useCallback((index) => {
|
|
185
|
+
const its = itemsRef.current;
|
|
186
|
+
return its && its.length > 0 ? its[index] : index;
|
|
187
|
+
}, []);
|
|
188
|
+
const [displayed, setDisplayed] = useState(() => items && items.length > 0 ? items[cycleMin] : cycleMin);
|
|
189
|
+
const subscribersRef = useRef(/* @__PURE__ */ new Set());
|
|
190
|
+
const subscribe = useCallback((fn) => {
|
|
191
|
+
subscribersRef.current.add(fn);
|
|
192
|
+
return () => {
|
|
193
|
+
subscribersRef.current.delete(fn);
|
|
194
|
+
};
|
|
195
|
+
}, []);
|
|
196
|
+
const onTick = useCallback((value) => {
|
|
197
|
+
subscribersRef.current.forEach((fn) => fn(value));
|
|
198
|
+
}, []);
|
|
199
|
+
const valueRef = useRef(cycleMin);
|
|
200
|
+
const { phase, step, start, freeze, reset } = useRafflePhase(inertia, initialPhase, useCallback(() => {
|
|
201
|
+
const v = displayValue(valueRef.current);
|
|
202
|
+
setDisplayed(v);
|
|
203
|
+
onSelect?.(v);
|
|
204
|
+
}, [displayValue, onSelect]));
|
|
205
|
+
const multiplier = getInertiaMultiplier(phase, step, inertia);
|
|
206
|
+
const cycleInterval = Math.round(Math.max(50, interval) * multiplier);
|
|
207
|
+
useNumberCycle({
|
|
208
|
+
min: cycleMin,
|
|
209
|
+
max: cycleMax,
|
|
210
|
+
interval: cycleInterval,
|
|
211
|
+
random,
|
|
212
|
+
running: phase === "starting" || phase === "running" || phase === "settling",
|
|
213
|
+
valueRef,
|
|
214
|
+
onTick
|
|
215
|
+
});
|
|
216
|
+
const ctxValue = useMemo(() => ({
|
|
217
|
+
phase,
|
|
218
|
+
step,
|
|
219
|
+
displayed,
|
|
220
|
+
cycleInterval,
|
|
221
|
+
inertia,
|
|
222
|
+
hasItems,
|
|
223
|
+
initialIndex: cycleMin,
|
|
224
|
+
valueRef,
|
|
225
|
+
displayValue,
|
|
226
|
+
subscribe,
|
|
227
|
+
start,
|
|
228
|
+
freeze,
|
|
229
|
+
reset
|
|
230
|
+
}), [
|
|
231
|
+
phase,
|
|
232
|
+
step,
|
|
233
|
+
displayed,
|
|
234
|
+
cycleInterval,
|
|
235
|
+
inertia,
|
|
236
|
+
hasItems,
|
|
237
|
+
cycleMin,
|
|
238
|
+
displayValue,
|
|
239
|
+
subscribe,
|
|
240
|
+
start,
|
|
241
|
+
freeze,
|
|
242
|
+
reset
|
|
243
|
+
]);
|
|
244
|
+
const selectionState = phase === "idle" ? "idle" : phase === "frozen" ? "frozen" : "running";
|
|
245
|
+
return createElement(as, {
|
|
246
|
+
className: joinClassNames("rrp", className),
|
|
247
|
+
"data-state": selectionState,
|
|
248
|
+
"data-phase": phase,
|
|
249
|
+
"data-inertia-step": step,
|
|
250
|
+
"data-inertia": inertia ? "" : void 0,
|
|
251
|
+
style
|
|
252
|
+
}, /* @__PURE__ */ jsx(RaffleContext.Provider, {
|
|
253
|
+
value: ctxValue,
|
|
254
|
+
children
|
|
255
|
+
}));
|
|
256
|
+
}
|
|
257
|
+
//#endregion
|
|
258
|
+
//#region src/components/RafflePick/RafflePickValue.tsx
|
|
259
|
+
function RafflePickValue({ animation = "roll", className, style, as = "span" }) {
|
|
260
|
+
const { phase, step, displayed, cycleInterval, valueRef, displayValue, subscribe } = useRaffleContext("RafflePick.Value");
|
|
261
|
+
const nodeRef = useRef(null);
|
|
262
|
+
const writeNode = useCallback((value) => {
|
|
263
|
+
const node = nodeRef.current;
|
|
264
|
+
if (!node) return;
|
|
265
|
+
const txt = String(displayValue(value));
|
|
266
|
+
node.textContent = txt;
|
|
267
|
+
node.setAttribute("data-value", txt);
|
|
268
|
+
if (typeof node.getAnimations === "function") {
|
|
269
|
+
const anims = node.getAnimations({ subtree: true });
|
|
270
|
+
for (let i = 0; i < anims.length; i++) anims[i].currentTime = 0;
|
|
271
|
+
}
|
|
272
|
+
}, [displayValue]);
|
|
273
|
+
useEffect(() => subscribe(writeNode), [subscribe, writeNode]);
|
|
274
|
+
const running = phase === "starting" || phase === "running" || phase === "settling";
|
|
275
|
+
useLayoutEffect(() => {
|
|
276
|
+
if (running) writeNode(valueRef.current);
|
|
277
|
+
});
|
|
278
|
+
return /* @__PURE__ */ jsx(as, {
|
|
279
|
+
ref: nodeRef,
|
|
280
|
+
className: useMemo(() => joinClassNames("rrp-value", className), [className]),
|
|
281
|
+
"data-animation": animation,
|
|
282
|
+
"data-value": displayed,
|
|
283
|
+
"data-phase": phase,
|
|
284
|
+
"data-inertia-step": step,
|
|
285
|
+
style: useMemo(() => ({
|
|
286
|
+
...style,
|
|
287
|
+
["--rrp-tick"]: `${cycleInterval}ms`
|
|
288
|
+
}), [style, cycleInterval]),
|
|
289
|
+
children: displayed
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
//#endregion
|
|
293
|
+
//#region src/components/RafflePick/RafflePickButton.tsx
|
|
294
|
+
function RafflePickButton({ className, style, children, startLabel, stopLabel, waitLabel }) {
|
|
295
|
+
const { phase, start, freeze, reset } = useRaffleContext("RafflePick.Button");
|
|
296
|
+
const running = phase === "starting" || phase === "running" || phase === "settling";
|
|
297
|
+
const handleClick = useCallback(() => {
|
|
298
|
+
if (phase === "settling") return;
|
|
299
|
+
if (running) {
|
|
300
|
+
freeze();
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
reset();
|
|
304
|
+
start();
|
|
305
|
+
}, [
|
|
306
|
+
phase,
|
|
307
|
+
running,
|
|
308
|
+
freeze,
|
|
309
|
+
reset,
|
|
310
|
+
start
|
|
311
|
+
]);
|
|
312
|
+
const cls = useMemo(() => joinClassNames("rrp-button", className), [className]);
|
|
313
|
+
let label;
|
|
314
|
+
if (phase === "settling") label = waitLabel ?? stopLabel ?? children;
|
|
315
|
+
else if (phase === "starting" || phase === "running") label = stopLabel ?? children;
|
|
316
|
+
else label = startLabel ?? children;
|
|
317
|
+
return /* @__PURE__ */ jsx("button", {
|
|
318
|
+
className: cls,
|
|
319
|
+
style,
|
|
320
|
+
disabled: phase === "settling",
|
|
321
|
+
onClick: handleClick,
|
|
322
|
+
"data-phase": phase,
|
|
323
|
+
children: label
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
//#endregion
|
|
327
|
+
//#region src/components/RafflePick/RafflePickCountdown.tsx
|
|
328
|
+
function RafflePickCountdown({ seconds, className, style, children }) {
|
|
329
|
+
const { phase } = useRaffleContext("RafflePick.Countdown");
|
|
330
|
+
if (phase !== "running") return null;
|
|
331
|
+
return /* @__PURE__ */ jsx(CountdownRunning, {
|
|
332
|
+
seconds,
|
|
333
|
+
className,
|
|
334
|
+
style,
|
|
335
|
+
children
|
|
336
|
+
}, `countdown-${seconds}`);
|
|
337
|
+
}
|
|
338
|
+
function CountdownRunning({ seconds, className, style, children }) {
|
|
339
|
+
const { freeze } = useRaffleContext("RafflePick.Countdown");
|
|
340
|
+
const [remaining, setRemaining] = useState(seconds);
|
|
341
|
+
useEffect(() => {
|
|
342
|
+
if (!seconds || seconds <= 0) return;
|
|
343
|
+
const tickId = setInterval(() => {
|
|
344
|
+
setRemaining((r) => r > 1 ? r - 1 : 0);
|
|
345
|
+
}, 1e3);
|
|
346
|
+
const stopId = setTimeout(() => freeze(), seconds * 1e3);
|
|
347
|
+
return () => {
|
|
348
|
+
clearInterval(tickId);
|
|
349
|
+
clearTimeout(stopId);
|
|
350
|
+
};
|
|
351
|
+
}, [seconds, freeze]);
|
|
352
|
+
const cls = useMemo(() => joinClassNames("rrp-countdown", className), [className]);
|
|
353
|
+
const mergedStyle = useMemo(() => ({
|
|
354
|
+
...style,
|
|
355
|
+
["--rrp-countdown"]: `${seconds}s`
|
|
356
|
+
}), [style, seconds]);
|
|
357
|
+
if (children) return /* @__PURE__ */ jsx("span", {
|
|
358
|
+
className: cls,
|
|
359
|
+
style: mergedStyle,
|
|
360
|
+
"aria-hidden": "true",
|
|
361
|
+
children: children(remaining)
|
|
362
|
+
});
|
|
363
|
+
return /* @__PURE__ */ jsxs("span", {
|
|
364
|
+
className: cls,
|
|
365
|
+
style: mergedStyle,
|
|
366
|
+
"aria-hidden": "true",
|
|
367
|
+
children: [/* @__PURE__ */ jsxs("svg", {
|
|
368
|
+
className: "rrp-countdown__svg",
|
|
369
|
+
viewBox: "0 0 36 36",
|
|
370
|
+
children: [/* @__PURE__ */ jsx("circle", {
|
|
371
|
+
className: "rrp-countdown__track",
|
|
372
|
+
cx: "18",
|
|
373
|
+
cy: "18",
|
|
374
|
+
r: "16"
|
|
375
|
+
}), /* @__PURE__ */ jsx("circle", {
|
|
376
|
+
className: "rrp-countdown__bar",
|
|
377
|
+
cx: "18",
|
|
378
|
+
cy: "18",
|
|
379
|
+
r: "16"
|
|
380
|
+
})]
|
|
381
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
382
|
+
className: "rrp-countdown__label",
|
|
383
|
+
children: remaining
|
|
384
|
+
})]
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
//#endregion
|
|
388
|
+
//#region src/components/RafflePick/RafflePickSlots.tsx
|
|
389
|
+
const pickRandom = (pool) => pool[Math.floor(Math.random() * pool.length)] ?? "";
|
|
390
|
+
const makeRefs = () => ({
|
|
391
|
+
root: null,
|
|
392
|
+
col: null,
|
|
393
|
+
prev: null,
|
|
394
|
+
curr: null,
|
|
395
|
+
next: null,
|
|
396
|
+
currChar: "",
|
|
397
|
+
stopped: false
|
|
398
|
+
});
|
|
399
|
+
function RafflePickSlots({ length = 3, chars = "0123456789", spinInterval = 80, staggerMs = 220, className, slotClassName, style, slotStyle, onResult }) {
|
|
400
|
+
const { phase } = useRaffleContext("RafflePick.Slots");
|
|
401
|
+
const slotsRef = useRef([]);
|
|
402
|
+
const stopTimersRef = useRef([]);
|
|
403
|
+
const finalRef = useRef([]);
|
|
404
|
+
const onResultRef = useRef(onResult);
|
|
405
|
+
useEffect(() => {
|
|
406
|
+
onResultRef.current = onResult;
|
|
407
|
+
});
|
|
408
|
+
if (slotsRef.current.length !== length) slotsRef.current = Array.from({ length }, (_, i) => slotsRef.current[i] ?? makeRefs());
|
|
409
|
+
const pool = useMemo(() => Array.from(chars), [chars]);
|
|
410
|
+
const safeInterval = Math.max(50, spinInterval);
|
|
411
|
+
const running = phase === "starting" || phase === "running";
|
|
412
|
+
const settling = phase === "settling" || phase === "frozen";
|
|
413
|
+
const writeSlot = (s, prev, curr, next) => {
|
|
414
|
+
if (s.prev) s.prev.textContent = prev;
|
|
415
|
+
if (s.curr) s.curr.textContent = curr;
|
|
416
|
+
if (s.next) s.next.textContent = next;
|
|
417
|
+
s.currChar = curr;
|
|
418
|
+
if (s.root) s.root.setAttribute("data-value", curr);
|
|
419
|
+
if (s.col && typeof s.col.getAnimations === "function") {
|
|
420
|
+
const anims = s.col.getAnimations();
|
|
421
|
+
for (let a = 0; a < anims.length; a++) anims[a].currentTime = 0;
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
useEffect(() => {
|
|
425
|
+
if (!running) return;
|
|
426
|
+
for (let i = 0; i < length; i++) {
|
|
427
|
+
const s = slotsRef.current[i];
|
|
428
|
+
s.stopped = false;
|
|
429
|
+
if (s.root) s.root.removeAttribute("data-stopped");
|
|
430
|
+
writeSlot(s, pickRandom(pool), pickRandom(pool), pickRandom(pool));
|
|
431
|
+
}
|
|
432
|
+
finalRef.current = new Array(length).fill("");
|
|
433
|
+
const id = setInterval(() => {
|
|
434
|
+
for (let i = 0; i < length; i++) {
|
|
435
|
+
const s = slotsRef.current[i];
|
|
436
|
+
if (s.stopped) continue;
|
|
437
|
+
const newPrev = s.currChar;
|
|
438
|
+
writeSlot(s, newPrev, s.next?.textContent ?? pickRandom(pool), pickRandom(pool));
|
|
439
|
+
}
|
|
440
|
+
}, safeInterval);
|
|
441
|
+
return () => clearInterval(id);
|
|
442
|
+
}, [
|
|
443
|
+
running,
|
|
444
|
+
length,
|
|
445
|
+
pool,
|
|
446
|
+
safeInterval
|
|
447
|
+
]);
|
|
448
|
+
useEffect(() => {
|
|
449
|
+
if (!settling) return;
|
|
450
|
+
const clear = () => {
|
|
451
|
+
stopTimersRef.current.forEach(clearTimeout);
|
|
452
|
+
stopTimersRef.current = [];
|
|
453
|
+
};
|
|
454
|
+
clear();
|
|
455
|
+
for (let i = 0; i < length; i++) {
|
|
456
|
+
const id = setTimeout(() => {
|
|
457
|
+
const s = slotsRef.current[i];
|
|
458
|
+
s.stopped = true;
|
|
459
|
+
if (s.root) s.root.setAttribute("data-stopped", "");
|
|
460
|
+
finalRef.current[i] = s.currChar;
|
|
461
|
+
if (i === length - 1) onResultRef.current?.(finalRef.current.join(""));
|
|
462
|
+
}, i * staggerMs);
|
|
463
|
+
stopTimersRef.current.push(id);
|
|
464
|
+
}
|
|
465
|
+
return clear;
|
|
466
|
+
}, [
|
|
467
|
+
settling,
|
|
468
|
+
length,
|
|
469
|
+
staggerMs
|
|
470
|
+
]);
|
|
471
|
+
useEffect(() => {
|
|
472
|
+
if (phase !== "idle") return;
|
|
473
|
+
const c = pool[0] ?? "";
|
|
474
|
+
for (let i = 0; i < length; i++) {
|
|
475
|
+
const s = slotsRef.current[i];
|
|
476
|
+
s.stopped = true;
|
|
477
|
+
writeSlot(s, c, c, c);
|
|
478
|
+
if (s.root) s.root.removeAttribute("data-stopped");
|
|
479
|
+
}
|
|
480
|
+
}, [
|
|
481
|
+
phase,
|
|
482
|
+
length,
|
|
483
|
+
pool
|
|
484
|
+
]);
|
|
485
|
+
const cls = useMemo(() => joinClassNames("rrp-slots", className), [className]);
|
|
486
|
+
const slotCls = useMemo(() => joinClassNames("rrp-slot", slotClassName), [slotClassName]);
|
|
487
|
+
const mergedSlotStyle = useMemo(() => ({
|
|
488
|
+
...slotStyle,
|
|
489
|
+
["--rrp-tick"]: `${safeInterval}ms`
|
|
490
|
+
}), [slotStyle, safeInterval]);
|
|
491
|
+
return /* @__PURE__ */ jsx("div", {
|
|
492
|
+
className: cls,
|
|
493
|
+
style,
|
|
494
|
+
"data-phase": phase,
|
|
495
|
+
children: Array.from({ length }, (_, i) => /* @__PURE__ */ jsx("span", {
|
|
496
|
+
ref: (n) => {
|
|
497
|
+
slotsRef.current[i].root = n;
|
|
498
|
+
},
|
|
499
|
+
className: slotCls,
|
|
500
|
+
style: mergedSlotStyle,
|
|
501
|
+
"data-slot-index": i,
|
|
502
|
+
children: /* @__PURE__ */ jsxs("span", {
|
|
503
|
+
className: "rrp-slot__col",
|
|
504
|
+
ref: (n) => {
|
|
505
|
+
slotsRef.current[i].col = n;
|
|
506
|
+
},
|
|
507
|
+
children: [
|
|
508
|
+
/* @__PURE__ */ jsx("span", {
|
|
509
|
+
className: "rrp-slot__cell",
|
|
510
|
+
ref: (n) => {
|
|
511
|
+
slotsRef.current[i].prev = n;
|
|
512
|
+
},
|
|
513
|
+
children: pool[0] ?? ""
|
|
514
|
+
}),
|
|
515
|
+
/* @__PURE__ */ jsx("span", {
|
|
516
|
+
className: "rrp-slot__cell",
|
|
517
|
+
ref: (n) => {
|
|
518
|
+
slotsRef.current[i].curr = n;
|
|
519
|
+
},
|
|
520
|
+
children: pool[0] ?? ""
|
|
521
|
+
}),
|
|
522
|
+
/* @__PURE__ */ jsx("span", {
|
|
523
|
+
className: "rrp-slot__cell",
|
|
524
|
+
ref: (n) => {
|
|
525
|
+
slotsRef.current[i].next = n;
|
|
526
|
+
},
|
|
527
|
+
children: pool[0] ?? ""
|
|
528
|
+
})
|
|
529
|
+
]
|
|
530
|
+
})
|
|
531
|
+
}, i))
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
//#endregion
|
|
535
|
+
//#region src/components/RafflePick/index.ts
|
|
536
|
+
const RafflePick = RafflePickRoot;
|
|
537
|
+
RafflePick.Value = RafflePickValue;
|
|
538
|
+
RafflePick.Button = RafflePickButton;
|
|
539
|
+
RafflePick.Countdown = RafflePickCountdown;
|
|
540
|
+
RafflePick.Slots = RafflePickSlots;
|
|
541
|
+
//#endregion
|
|
542
|
+
export { RaffleContext, RafflePick, RafflePickButton, RafflePickCountdown, RafflePickSlots, RafflePickValue, useRaffleContext };
|