react-raffle-picker 0.1.0 → 0.2.1
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 -16
- package/dist/index.cjs +88 -10
- package/dist/index.d.cts +8 -0
- package/dist/index.d.mts +8 -0
- package/dist/index.mjs +88 -10
- package/dist/styles.css +63 -0
- package/package.json +13 -8
package/README.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
<div align="center">
|
|
2
|
-
<img src="https://i.ibb.co/
|
|
2
|
+
<img src="https://i.ibb.co/yB50hJTv/logo-nbg.png" alt="react-raffle-picker logo placeholder" width="160" />
|
|
3
3
|
|
|
4
4
|
<h1>react-raffle-picker</h1>
|
|
5
5
|
|
|
6
|
-
<p><strong>
|
|
6
|
+
<p><strong>React like you just won. The raffle picker your UI deserves.</strong></p>
|
|
7
7
|
|
|
8
8
|
<p>
|
|
9
9
|
<a href="https://react-raffle-one.vercel.app/">Live demo</a>
|
|
@@ -69,31 +69,48 @@ This means:
|
|
|
69
69
|
```tsx
|
|
70
70
|
import { RafflePick } from 'react-raffle-picker'
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
;<RafflePick min={1} max={100} interval={100} inertia onSelect={(v) => console.log(v)}>
|
|
73
73
|
<RafflePick.Value animation="roll" className="my-value" />
|
|
74
74
|
<RafflePick.Button startLabel="Pick" stopLabel="Stop" />
|
|
75
75
|
</RafflePick>
|
|
76
76
|
```
|
|
77
77
|
|
|
78
|
+
### Styles
|
|
79
|
+
|
|
80
|
+
The library is headless — no global stylesheet is required for `Value`, `Button`, or `Countdown`. You bring your own CSS.
|
|
81
|
+
|
|
82
|
+
For the **slot reel** (`<RafflePick.Slots>`), a minimal stylesheet _is_ required to make the column animate. Two ways to load it:
|
|
83
|
+
|
|
84
|
+
1. **Auto-injected at runtime** — `<RafflePick.Slots>` injects a `<style data-rrp-slot-base>` tag into `document.head` on first mount. No action needed in CSR apps.
|
|
85
|
+
2. **Static import** (recommended for SSR / strict CSP / full control):
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import 'react-raffle-picker/styles.css'
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
This file also includes opt-in keyframes for `<RafflePick.Value animation="roll|fade|blur|reel" />`. Override or replace any selector in your own CSS.
|
|
92
|
+
|
|
78
93
|
## Components
|
|
79
94
|
|
|
80
95
|
### `<RafflePick>` (root)
|
|
81
96
|
|
|
82
97
|
Provides context. Renders an optional wrapper element (`as` prop, default `'div'`).
|
|
83
98
|
|
|
84
|
-
| Prop
|
|
85
|
-
|
|
|
86
|
-
| `min`, `max`
|
|
87
|
-
| `items`
|
|
88
|
-
| `interval`
|
|
89
|
-
| `random`
|
|
90
|
-
| `inertia`
|
|
91
|
-
| `autoStart`
|
|
92
|
-
| `
|
|
93
|
-
| `
|
|
94
|
-
| `
|
|
95
|
-
| `
|
|
96
|
-
| `
|
|
99
|
+
| Prop | Type | Default | Notes |
|
|
100
|
+
| -------------- | --------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
101
|
+
| `min`, `max` | `number` | `1`, `100` | Numeric range. Ignored if `items` provided. |
|
|
102
|
+
| `items` | `string[]` | — | Switches to item mode (cycles through names). |
|
|
103
|
+
| `interval` | `number` (ms, clamped ≥ 50) | `100` | Tick speed. |
|
|
104
|
+
| `random` | `boolean` | `true` | Random pick vs sequential. |
|
|
105
|
+
| `inertia` | `boolean` | `false` | Soft start / soft stop ramp. |
|
|
106
|
+
| `autoStart` | `boolean` | `true` | Begin cycling on mount. |
|
|
107
|
+
| `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. |
|
|
108
|
+
| `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. |
|
|
109
|
+
| `onSelect` | `(value) => void` | — | Fires once per round on freeze. |
|
|
110
|
+
| `as` | `ElementType` | `'div'` | Wrapper tag. |
|
|
111
|
+
| `className` | `string` | — | Wrapper class. |
|
|
112
|
+
| `style` | `CSSProperties` | — | Wrapper style. |
|
|
113
|
+
| `children` | `ReactNode` | — | Sub-components. |
|
|
97
114
|
|
|
98
115
|
### `<RafflePick.Value>`
|
|
99
116
|
|
|
@@ -136,6 +153,8 @@ Schedules auto-freeze after `seconds`. Renders an SVG ring + numeric label by de
|
|
|
136
153
|
|
|
137
154
|
Independent multi-reel slot machine. Each reel ticks on its own and stops with a stagger.
|
|
138
155
|
|
|
156
|
+
`initialValue` (root prop) seeds reels char-by-char before the first run. `finalValue` rigs the freeze so each reel lands on the corresponding character — useful for predetermined winners or scripted demos.
|
|
157
|
+
|
|
139
158
|
| Prop | Type | Default | Notes |
|
|
140
159
|
| -------------------------------------------------- | -------------------------- | -------------- | -------------------------------------------- |
|
|
141
160
|
| `length` | `number` | `3` | Number of reels. |
|
|
@@ -166,6 +185,23 @@ Independent multi-reel slot machine. Each reel ticks on its own and stops with a
|
|
|
166
185
|
</RafflePick>
|
|
167
186
|
```
|
|
168
187
|
|
|
188
|
+
### Rigged draw with predetermined winner
|
|
189
|
+
|
|
190
|
+
`finalValue` lands the freeze on a specific value while the cycle still looks random — useful for staged demos, scripted reveals, or showing a known winner.
|
|
191
|
+
|
|
192
|
+
```tsx
|
|
193
|
+
<RafflePick
|
|
194
|
+
items={['Alice', 'Bob', 'Carol']}
|
|
195
|
+
initialValue="Alice"
|
|
196
|
+
finalValue="Bob"
|
|
197
|
+
autoStart={false}
|
|
198
|
+
onSelect={(winner) => console.log(winner)} // always 'Bob'
|
|
199
|
+
>
|
|
200
|
+
<RafflePick.Value animation="reel" />
|
|
201
|
+
<RafflePick.Button startLabel="Draw" stopLabel="Reveal" />
|
|
202
|
+
</RafflePick>
|
|
203
|
+
```
|
|
204
|
+
|
|
169
205
|
### Slot machine with custom result handler
|
|
170
206
|
|
|
171
207
|
```tsx
|
package/dist/index.cjs
CHANGED
|
@@ -172,7 +172,7 @@ const useRaffleContext = (componentName) => {
|
|
|
172
172
|
};
|
|
173
173
|
//#endregion
|
|
174
174
|
//#region src/components/RafflePick/RafflePick.tsx
|
|
175
|
-
function RafflePickRoot({ items, min = 1, max = 100, interval = 100, random = true, inertia = false, autoStart = true, onSelect, as = "div", className, style, children }) {
|
|
175
|
+
function RafflePickRoot({ items, min = 1, max = 100, interval = 100, random = true, inertia = false, autoStart = true, initialValue, finalValue, onSelect, as = "div", className, style, children }) {
|
|
176
176
|
const itemCount = items?.length ?? 0;
|
|
177
177
|
const hasItems = itemCount > 0;
|
|
178
178
|
const cycleMin = hasItems ? 0 : min;
|
|
@@ -186,7 +186,33 @@ function RafflePickRoot({ items, min = 1, max = 100, interval = 100, random = tr
|
|
|
186
186
|
const its = itemsRef.current;
|
|
187
187
|
return its && its.length > 0 ? its[index] : index;
|
|
188
188
|
}, []);
|
|
189
|
-
const
|
|
189
|
+
const valueToIndex = (0, react.useCallback)((value) => {
|
|
190
|
+
if (value === void 0) return void 0;
|
|
191
|
+
const its = itemsRef.current;
|
|
192
|
+
if (its && its.length > 0) {
|
|
193
|
+
const i = its.indexOf(String(value));
|
|
194
|
+
return i >= 0 ? i : void 0;
|
|
195
|
+
}
|
|
196
|
+
return typeof value === "number" ? value : void 0;
|
|
197
|
+
}, []);
|
|
198
|
+
const initialIndex = (() => {
|
|
199
|
+
if (initialValue === void 0) return cycleMin;
|
|
200
|
+
if (items && items.length > 0) {
|
|
201
|
+
const i = items.indexOf(String(initialValue));
|
|
202
|
+
return i >= 0 ? i : cycleMin;
|
|
203
|
+
}
|
|
204
|
+
return typeof initialValue === "number" ? initialValue : cycleMin;
|
|
205
|
+
})();
|
|
206
|
+
const [displayed, setDisplayed] = (0, react.useState)(() => {
|
|
207
|
+
if (initialValue !== void 0) {
|
|
208
|
+
const its = items;
|
|
209
|
+
if (its && its.length > 0) {
|
|
210
|
+
const i = its.indexOf(String(initialValue));
|
|
211
|
+
if (i >= 0) return its[i];
|
|
212
|
+
} else if (typeof initialValue === "number") return initialValue;
|
|
213
|
+
}
|
|
214
|
+
return items && items.length > 0 ? items[cycleMin] : cycleMin;
|
|
215
|
+
});
|
|
190
216
|
const subscribersRef = (0, react.useRef)(/* @__PURE__ */ new Set());
|
|
191
217
|
const subscribe = (0, react.useCallback)((fn) => {
|
|
192
218
|
subscribersRef.current.add(fn);
|
|
@@ -197,12 +223,22 @@ function RafflePickRoot({ items, min = 1, max = 100, interval = 100, random = tr
|
|
|
197
223
|
const onTick = (0, react.useCallback)((value) => {
|
|
198
224
|
subscribersRef.current.forEach((fn) => fn(value));
|
|
199
225
|
}, []);
|
|
200
|
-
const valueRef = (0, react.useRef)(
|
|
226
|
+
const valueRef = (0, react.useRef)(initialIndex);
|
|
227
|
+
const finalValueRef = (0, react.useRef)(finalValue);
|
|
228
|
+
(0, react.useEffect)(() => {
|
|
229
|
+
finalValueRef.current = finalValue;
|
|
230
|
+
}, [finalValue]);
|
|
201
231
|
const { phase, step, start, freeze, reset } = useRafflePhase(inertia, initialPhase, (0, react.useCallback)(() => {
|
|
232
|
+
const forced = valueToIndex(finalValueRef.current);
|
|
233
|
+
if (forced !== void 0) valueRef.current = forced;
|
|
202
234
|
const v = displayValue(valueRef.current);
|
|
203
235
|
setDisplayed(v);
|
|
204
236
|
onSelect?.(v);
|
|
205
|
-
}, [
|
|
237
|
+
}, [
|
|
238
|
+
displayValue,
|
|
239
|
+
onSelect,
|
|
240
|
+
valueToIndex
|
|
241
|
+
]));
|
|
206
242
|
const multiplier = getInertiaMultiplier(phase, step, inertia);
|
|
207
243
|
const cycleInterval = Math.round(Math.max(50, interval) * multiplier);
|
|
208
244
|
useNumberCycle({
|
|
@@ -221,7 +257,9 @@ function RafflePickRoot({ items, min = 1, max = 100, interval = 100, random = tr
|
|
|
221
257
|
cycleInterval,
|
|
222
258
|
inertia,
|
|
223
259
|
hasItems,
|
|
224
|
-
initialIndex
|
|
260
|
+
initialIndex,
|
|
261
|
+
initialValue,
|
|
262
|
+
finalValue,
|
|
225
263
|
valueRef,
|
|
226
264
|
displayValue,
|
|
227
265
|
subscribe,
|
|
@@ -235,7 +273,9 @@ function RafflePickRoot({ items, min = 1, max = 100, interval = 100, random = tr
|
|
|
235
273
|
cycleInterval,
|
|
236
274
|
inertia,
|
|
237
275
|
hasItems,
|
|
238
|
-
|
|
276
|
+
initialIndex,
|
|
277
|
+
initialValue,
|
|
278
|
+
finalValue,
|
|
239
279
|
displayValue,
|
|
240
280
|
subscribe,
|
|
241
281
|
start,
|
|
@@ -388,6 +428,27 @@ function CountdownRunning({ seconds, className, style, children }) {
|
|
|
388
428
|
//#endregion
|
|
389
429
|
//#region src/components/RafflePick/RafflePickSlots.tsx
|
|
390
430
|
const pickRandom = (pool) => pool[Math.floor(Math.random() * pool.length)] ?? "";
|
|
431
|
+
const SLOT_BASE_CSS = `
|
|
432
|
+
.rrp-slot{display:inline-block;overflow:hidden;height:1em;line-height:1em;vertical-align:baseline}
|
|
433
|
+
.rrp-slot__col{display:flex;flex-direction:column;transform:translateY(-1em);animation:rrp-slot-reel var(--rrp-tick,80ms) linear infinite}
|
|
434
|
+
.rrp-slot__cell{display:block;height:1em;line-height:1em}
|
|
435
|
+
.rrp-slot[data-stopped] .rrp-slot__col{animation:none;transform:translateY(-1em)}
|
|
436
|
+
@keyframes rrp-slot-reel{from{transform:translateY(-1em)}to{transform:translateY(-2em)}}
|
|
437
|
+
`;
|
|
438
|
+
let slotStylesInjected = false;
|
|
439
|
+
const injectSlotStyles = () => {
|
|
440
|
+
if (slotStylesInjected) return;
|
|
441
|
+
if (typeof document === "undefined") return;
|
|
442
|
+
if (document.querySelector("style[data-rrp-slot-base]")) {
|
|
443
|
+
slotStylesInjected = true;
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
const tag = document.createElement("style");
|
|
447
|
+
tag.setAttribute("data-rrp-slot-base", "");
|
|
448
|
+
tag.textContent = SLOT_BASE_CSS;
|
|
449
|
+
document.head.appendChild(tag);
|
|
450
|
+
slotStylesInjected = true;
|
|
451
|
+
};
|
|
391
452
|
const makeRefs = () => ({
|
|
392
453
|
root: null,
|
|
393
454
|
col: null,
|
|
@@ -398,7 +459,20 @@ const makeRefs = () => ({
|
|
|
398
459
|
stopped: false
|
|
399
460
|
});
|
|
400
461
|
function RafflePickSlots({ length = 3, chars = "0123456789", spinInterval = 80, staggerMs = 220, className, slotClassName, style, slotStyle, onResult }) {
|
|
401
|
-
|
|
462
|
+
injectSlotStyles();
|
|
463
|
+
const { phase, initialValue, finalValue } = useRaffleContext("RafflePick.Slots");
|
|
464
|
+
const initialChars = (0, react.useMemo)(() => {
|
|
465
|
+
const seed = typeof initialValue === "string" ? initialValue : "";
|
|
466
|
+
return Array.from({ length }, (_, i) => seed[i] ?? "");
|
|
467
|
+
}, [initialValue, length]);
|
|
468
|
+
const finalChars = (0, react.useMemo)(() => {
|
|
469
|
+
if (typeof finalValue !== "string") return null;
|
|
470
|
+
return Array.from({ length }, (_, i) => finalValue[i] ?? "");
|
|
471
|
+
}, [finalValue, length]);
|
|
472
|
+
const finalCharsRef = (0, react.useRef)(finalChars);
|
|
473
|
+
(0, react.useEffect)(() => {
|
|
474
|
+
finalCharsRef.current = finalChars;
|
|
475
|
+
}, [finalChars]);
|
|
402
476
|
const slotsRef = (0, react.useRef)([]);
|
|
403
477
|
const stopTimersRef = (0, react.useRef)([]);
|
|
404
478
|
const finalRef = (0, react.useRef)([]);
|
|
@@ -456,6 +530,8 @@ function RafflePickSlots({ length = 3, chars = "0123456789", spinInterval = 80,
|
|
|
456
530
|
for (let i = 0; i < length; i++) {
|
|
457
531
|
const id = setTimeout(() => {
|
|
458
532
|
const s = slotsRef.current[i];
|
|
533
|
+
const forced = finalCharsRef.current?.[i];
|
|
534
|
+
if (forced) writeSlot(s, s.currChar, forced, forced);
|
|
459
535
|
s.stopped = true;
|
|
460
536
|
if (s.root) s.root.setAttribute("data-stopped", "");
|
|
461
537
|
finalRef.current[i] = s.currChar;
|
|
@@ -471,17 +547,19 @@ function RafflePickSlots({ length = 3, chars = "0123456789", spinInterval = 80,
|
|
|
471
547
|
]);
|
|
472
548
|
(0, react.useEffect)(() => {
|
|
473
549
|
if (phase !== "idle") return;
|
|
474
|
-
const
|
|
550
|
+
const fallback = pool[0] ?? "";
|
|
475
551
|
for (let i = 0; i < length; i++) {
|
|
476
552
|
const s = slotsRef.current[i];
|
|
553
|
+
const c = initialChars[i] || fallback;
|
|
477
554
|
s.stopped = true;
|
|
478
555
|
writeSlot(s, c, c, c);
|
|
479
|
-
if (s.root) s.root.
|
|
556
|
+
if (s.root) s.root.setAttribute("data-stopped", "");
|
|
480
557
|
}
|
|
481
558
|
}, [
|
|
482
559
|
phase,
|
|
483
560
|
length,
|
|
484
|
-
pool
|
|
561
|
+
pool,
|
|
562
|
+
initialChars
|
|
485
563
|
]);
|
|
486
564
|
const cls = (0, react.useMemo)(() => joinClassNames("rrp-slots", className), [className]);
|
|
487
565
|
const slotCls = (0, react.useMemo)(() => joinClassNames("rrp-slot", slotClassName), [slotClassName]);
|
package/dist/index.d.cts
CHANGED
|
@@ -13,6 +13,10 @@ interface RafflePickRootProps {
|
|
|
13
13
|
random?: boolean;
|
|
14
14
|
inertia?: boolean;
|
|
15
15
|
autoStart?: boolean;
|
|
16
|
+
/** Value shown before first run. Number in min/max mode, string in items mode. */
|
|
17
|
+
initialValue?: RafflePickValue$1;
|
|
18
|
+
/** When set, settle always lands on this value while cycle still appears random. */
|
|
19
|
+
finalValue?: RafflePickValue$1;
|
|
16
20
|
onSelect?: (value: RafflePickValue$1) => void;
|
|
17
21
|
as?: ElementType;
|
|
18
22
|
className?: string;
|
|
@@ -71,6 +75,8 @@ declare function RafflePickRoot({
|
|
|
71
75
|
random,
|
|
72
76
|
inertia,
|
|
73
77
|
autoStart,
|
|
78
|
+
initialValue,
|
|
79
|
+
finalValue,
|
|
74
80
|
onSelect,
|
|
75
81
|
as,
|
|
76
82
|
className,
|
|
@@ -129,6 +135,8 @@ interface RaffleContextValue {
|
|
|
129
135
|
inertia: boolean;
|
|
130
136
|
hasItems: boolean;
|
|
131
137
|
initialIndex: number;
|
|
138
|
+
initialValue?: RafflePickValue$1;
|
|
139
|
+
finalValue?: RafflePickValue$1;
|
|
132
140
|
valueRef: RefObject<number>;
|
|
133
141
|
displayValue: (index: number) => RafflePickValue$1;
|
|
134
142
|
subscribe: (fn: (value: number) => void) => () => void;
|
package/dist/index.d.mts
CHANGED
|
@@ -13,6 +13,10 @@ interface RafflePickRootProps {
|
|
|
13
13
|
random?: boolean;
|
|
14
14
|
inertia?: boolean;
|
|
15
15
|
autoStart?: boolean;
|
|
16
|
+
/** Value shown before first run. Number in min/max mode, string in items mode. */
|
|
17
|
+
initialValue?: RafflePickValue$1;
|
|
18
|
+
/** When set, settle always lands on this value while cycle still appears random. */
|
|
19
|
+
finalValue?: RafflePickValue$1;
|
|
16
20
|
onSelect?: (value: RafflePickValue$1) => void;
|
|
17
21
|
as?: ElementType;
|
|
18
22
|
className?: string;
|
|
@@ -71,6 +75,8 @@ declare function RafflePickRoot({
|
|
|
71
75
|
random,
|
|
72
76
|
inertia,
|
|
73
77
|
autoStart,
|
|
78
|
+
initialValue,
|
|
79
|
+
finalValue,
|
|
74
80
|
onSelect,
|
|
75
81
|
as,
|
|
76
82
|
className,
|
|
@@ -129,6 +135,8 @@ interface RaffleContextValue {
|
|
|
129
135
|
inertia: boolean;
|
|
130
136
|
hasItems: boolean;
|
|
131
137
|
initialIndex: number;
|
|
138
|
+
initialValue?: RafflePickValue$1;
|
|
139
|
+
finalValue?: RafflePickValue$1;
|
|
132
140
|
valueRef: RefObject<number>;
|
|
133
141
|
displayValue: (index: number) => RafflePickValue$1;
|
|
134
142
|
subscribe: (fn: (value: number) => void) => () => void;
|
package/dist/index.mjs
CHANGED
|
@@ -171,7 +171,7 @@ const useRaffleContext = (componentName) => {
|
|
|
171
171
|
};
|
|
172
172
|
//#endregion
|
|
173
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 }) {
|
|
174
|
+
function RafflePickRoot({ items, min = 1, max = 100, interval = 100, random = true, inertia = false, autoStart = true, initialValue, finalValue, onSelect, as = "div", className, style, children }) {
|
|
175
175
|
const itemCount = items?.length ?? 0;
|
|
176
176
|
const hasItems = itemCount > 0;
|
|
177
177
|
const cycleMin = hasItems ? 0 : min;
|
|
@@ -185,7 +185,33 @@ function RafflePickRoot({ items, min = 1, max = 100, interval = 100, random = tr
|
|
|
185
185
|
const its = itemsRef.current;
|
|
186
186
|
return its && its.length > 0 ? its[index] : index;
|
|
187
187
|
}, []);
|
|
188
|
-
const
|
|
188
|
+
const valueToIndex = useCallback((value) => {
|
|
189
|
+
if (value === void 0) return void 0;
|
|
190
|
+
const its = itemsRef.current;
|
|
191
|
+
if (its && its.length > 0) {
|
|
192
|
+
const i = its.indexOf(String(value));
|
|
193
|
+
return i >= 0 ? i : void 0;
|
|
194
|
+
}
|
|
195
|
+
return typeof value === "number" ? value : void 0;
|
|
196
|
+
}, []);
|
|
197
|
+
const initialIndex = (() => {
|
|
198
|
+
if (initialValue === void 0) return cycleMin;
|
|
199
|
+
if (items && items.length > 0) {
|
|
200
|
+
const i = items.indexOf(String(initialValue));
|
|
201
|
+
return i >= 0 ? i : cycleMin;
|
|
202
|
+
}
|
|
203
|
+
return typeof initialValue === "number" ? initialValue : cycleMin;
|
|
204
|
+
})();
|
|
205
|
+
const [displayed, setDisplayed] = useState(() => {
|
|
206
|
+
if (initialValue !== void 0) {
|
|
207
|
+
const its = items;
|
|
208
|
+
if (its && its.length > 0) {
|
|
209
|
+
const i = its.indexOf(String(initialValue));
|
|
210
|
+
if (i >= 0) return its[i];
|
|
211
|
+
} else if (typeof initialValue === "number") return initialValue;
|
|
212
|
+
}
|
|
213
|
+
return items && items.length > 0 ? items[cycleMin] : cycleMin;
|
|
214
|
+
});
|
|
189
215
|
const subscribersRef = useRef(/* @__PURE__ */ new Set());
|
|
190
216
|
const subscribe = useCallback((fn) => {
|
|
191
217
|
subscribersRef.current.add(fn);
|
|
@@ -196,12 +222,22 @@ function RafflePickRoot({ items, min = 1, max = 100, interval = 100, random = tr
|
|
|
196
222
|
const onTick = useCallback((value) => {
|
|
197
223
|
subscribersRef.current.forEach((fn) => fn(value));
|
|
198
224
|
}, []);
|
|
199
|
-
const valueRef = useRef(
|
|
225
|
+
const valueRef = useRef(initialIndex);
|
|
226
|
+
const finalValueRef = useRef(finalValue);
|
|
227
|
+
useEffect(() => {
|
|
228
|
+
finalValueRef.current = finalValue;
|
|
229
|
+
}, [finalValue]);
|
|
200
230
|
const { phase, step, start, freeze, reset } = useRafflePhase(inertia, initialPhase, useCallback(() => {
|
|
231
|
+
const forced = valueToIndex(finalValueRef.current);
|
|
232
|
+
if (forced !== void 0) valueRef.current = forced;
|
|
201
233
|
const v = displayValue(valueRef.current);
|
|
202
234
|
setDisplayed(v);
|
|
203
235
|
onSelect?.(v);
|
|
204
|
-
}, [
|
|
236
|
+
}, [
|
|
237
|
+
displayValue,
|
|
238
|
+
onSelect,
|
|
239
|
+
valueToIndex
|
|
240
|
+
]));
|
|
205
241
|
const multiplier = getInertiaMultiplier(phase, step, inertia);
|
|
206
242
|
const cycleInterval = Math.round(Math.max(50, interval) * multiplier);
|
|
207
243
|
useNumberCycle({
|
|
@@ -220,7 +256,9 @@ function RafflePickRoot({ items, min = 1, max = 100, interval = 100, random = tr
|
|
|
220
256
|
cycleInterval,
|
|
221
257
|
inertia,
|
|
222
258
|
hasItems,
|
|
223
|
-
initialIndex
|
|
259
|
+
initialIndex,
|
|
260
|
+
initialValue,
|
|
261
|
+
finalValue,
|
|
224
262
|
valueRef,
|
|
225
263
|
displayValue,
|
|
226
264
|
subscribe,
|
|
@@ -234,7 +272,9 @@ function RafflePickRoot({ items, min = 1, max = 100, interval = 100, random = tr
|
|
|
234
272
|
cycleInterval,
|
|
235
273
|
inertia,
|
|
236
274
|
hasItems,
|
|
237
|
-
|
|
275
|
+
initialIndex,
|
|
276
|
+
initialValue,
|
|
277
|
+
finalValue,
|
|
238
278
|
displayValue,
|
|
239
279
|
subscribe,
|
|
240
280
|
start,
|
|
@@ -387,6 +427,27 @@ function CountdownRunning({ seconds, className, style, children }) {
|
|
|
387
427
|
//#endregion
|
|
388
428
|
//#region src/components/RafflePick/RafflePickSlots.tsx
|
|
389
429
|
const pickRandom = (pool) => pool[Math.floor(Math.random() * pool.length)] ?? "";
|
|
430
|
+
const SLOT_BASE_CSS = `
|
|
431
|
+
.rrp-slot{display:inline-block;overflow:hidden;height:1em;line-height:1em;vertical-align:baseline}
|
|
432
|
+
.rrp-slot__col{display:flex;flex-direction:column;transform:translateY(-1em);animation:rrp-slot-reel var(--rrp-tick,80ms) linear infinite}
|
|
433
|
+
.rrp-slot__cell{display:block;height:1em;line-height:1em}
|
|
434
|
+
.rrp-slot[data-stopped] .rrp-slot__col{animation:none;transform:translateY(-1em)}
|
|
435
|
+
@keyframes rrp-slot-reel{from{transform:translateY(-1em)}to{transform:translateY(-2em)}}
|
|
436
|
+
`;
|
|
437
|
+
let slotStylesInjected = false;
|
|
438
|
+
const injectSlotStyles = () => {
|
|
439
|
+
if (slotStylesInjected) return;
|
|
440
|
+
if (typeof document === "undefined") return;
|
|
441
|
+
if (document.querySelector("style[data-rrp-slot-base]")) {
|
|
442
|
+
slotStylesInjected = true;
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
const tag = document.createElement("style");
|
|
446
|
+
tag.setAttribute("data-rrp-slot-base", "");
|
|
447
|
+
tag.textContent = SLOT_BASE_CSS;
|
|
448
|
+
document.head.appendChild(tag);
|
|
449
|
+
slotStylesInjected = true;
|
|
450
|
+
};
|
|
390
451
|
const makeRefs = () => ({
|
|
391
452
|
root: null,
|
|
392
453
|
col: null,
|
|
@@ -397,7 +458,20 @@ const makeRefs = () => ({
|
|
|
397
458
|
stopped: false
|
|
398
459
|
});
|
|
399
460
|
function RafflePickSlots({ length = 3, chars = "0123456789", spinInterval = 80, staggerMs = 220, className, slotClassName, style, slotStyle, onResult }) {
|
|
400
|
-
|
|
461
|
+
injectSlotStyles();
|
|
462
|
+
const { phase, initialValue, finalValue } = useRaffleContext("RafflePick.Slots");
|
|
463
|
+
const initialChars = useMemo(() => {
|
|
464
|
+
const seed = typeof initialValue === "string" ? initialValue : "";
|
|
465
|
+
return Array.from({ length }, (_, i) => seed[i] ?? "");
|
|
466
|
+
}, [initialValue, length]);
|
|
467
|
+
const finalChars = useMemo(() => {
|
|
468
|
+
if (typeof finalValue !== "string") return null;
|
|
469
|
+
return Array.from({ length }, (_, i) => finalValue[i] ?? "");
|
|
470
|
+
}, [finalValue, length]);
|
|
471
|
+
const finalCharsRef = useRef(finalChars);
|
|
472
|
+
useEffect(() => {
|
|
473
|
+
finalCharsRef.current = finalChars;
|
|
474
|
+
}, [finalChars]);
|
|
401
475
|
const slotsRef = useRef([]);
|
|
402
476
|
const stopTimersRef = useRef([]);
|
|
403
477
|
const finalRef = useRef([]);
|
|
@@ -455,6 +529,8 @@ function RafflePickSlots({ length = 3, chars = "0123456789", spinInterval = 80,
|
|
|
455
529
|
for (let i = 0; i < length; i++) {
|
|
456
530
|
const id = setTimeout(() => {
|
|
457
531
|
const s = slotsRef.current[i];
|
|
532
|
+
const forced = finalCharsRef.current?.[i];
|
|
533
|
+
if (forced) writeSlot(s, s.currChar, forced, forced);
|
|
458
534
|
s.stopped = true;
|
|
459
535
|
if (s.root) s.root.setAttribute("data-stopped", "");
|
|
460
536
|
finalRef.current[i] = s.currChar;
|
|
@@ -470,17 +546,19 @@ function RafflePickSlots({ length = 3, chars = "0123456789", spinInterval = 80,
|
|
|
470
546
|
]);
|
|
471
547
|
useEffect(() => {
|
|
472
548
|
if (phase !== "idle") return;
|
|
473
|
-
const
|
|
549
|
+
const fallback = pool[0] ?? "";
|
|
474
550
|
for (let i = 0; i < length; i++) {
|
|
475
551
|
const s = slotsRef.current[i];
|
|
552
|
+
const c = initialChars[i] || fallback;
|
|
476
553
|
s.stopped = true;
|
|
477
554
|
writeSlot(s, c, c, c);
|
|
478
|
-
if (s.root) s.root.
|
|
555
|
+
if (s.root) s.root.setAttribute("data-stopped", "");
|
|
479
556
|
}
|
|
480
557
|
}, [
|
|
481
558
|
phase,
|
|
482
559
|
length,
|
|
483
|
-
pool
|
|
560
|
+
pool,
|
|
561
|
+
initialChars
|
|
484
562
|
]);
|
|
485
563
|
const cls = useMemo(() => joinClassNames("rrp-slots", className), [className]);
|
|
486
564
|
const slotCls = useMemo(() => joinClassNames("rrp-slot", slotClassName), [slotClassName]);
|
package/dist/styles.css
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/* react-raffle-picker — base styles */
|
|
2
|
+
/* Optional. Slot reel animation is also auto-injected at runtime. */
|
|
3
|
+
/* Import via: import 'react-raffle-picker/styles.css' */
|
|
4
|
+
|
|
5
|
+
.rrp-slot {
|
|
6
|
+
display: inline-block;
|
|
7
|
+
overflow: hidden;
|
|
8
|
+
height: 1em;
|
|
9
|
+
line-height: 1em;
|
|
10
|
+
vertical-align: baseline;
|
|
11
|
+
}
|
|
12
|
+
.rrp-slot__col {
|
|
13
|
+
display: flex;
|
|
14
|
+
flex-direction: column;
|
|
15
|
+
transform: translateY(-1em);
|
|
16
|
+
animation: rrp-slot-reel var(--rrp-tick, 80ms) linear infinite;
|
|
17
|
+
}
|
|
18
|
+
.rrp-slot__cell {
|
|
19
|
+
display: block;
|
|
20
|
+
height: 1em;
|
|
21
|
+
line-height: 1em;
|
|
22
|
+
}
|
|
23
|
+
.rrp-slot[data-stopped] .rrp-slot__col {
|
|
24
|
+
animation: none;
|
|
25
|
+
transform: translateY(-1em);
|
|
26
|
+
}
|
|
27
|
+
@keyframes rrp-slot-reel {
|
|
28
|
+
from { transform: translateY(-1em); }
|
|
29
|
+
to { transform: translateY(-2em); }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* Value animations: opt-in via <RafflePick.Value animation="..." /> */
|
|
33
|
+
.rrp-value {
|
|
34
|
+
display: inline-block;
|
|
35
|
+
}
|
|
36
|
+
.rrp-value[data-animation='roll'][data-phase='running'] {
|
|
37
|
+
animation: rrp-value-roll var(--rrp-tick, 100ms) linear;
|
|
38
|
+
}
|
|
39
|
+
.rrp-value[data-animation='fade'][data-phase='running'] {
|
|
40
|
+
animation: rrp-value-fade var(--rrp-tick, 100ms) linear;
|
|
41
|
+
}
|
|
42
|
+
.rrp-value[data-animation='blur'][data-phase='running'] {
|
|
43
|
+
animation: rrp-value-blur var(--rrp-tick, 100ms) linear;
|
|
44
|
+
}
|
|
45
|
+
.rrp-value[data-animation='reel'][data-phase='running'] {
|
|
46
|
+
animation: rrp-value-reel var(--rrp-tick, 100ms) linear;
|
|
47
|
+
}
|
|
48
|
+
@keyframes rrp-value-roll {
|
|
49
|
+
from { transform: translateY(-0.4em); opacity: 0.4; }
|
|
50
|
+
to { transform: translateY(0); opacity: 1; }
|
|
51
|
+
}
|
|
52
|
+
@keyframes rrp-value-fade {
|
|
53
|
+
from { opacity: 0.2; }
|
|
54
|
+
to { opacity: 1; }
|
|
55
|
+
}
|
|
56
|
+
@keyframes rrp-value-blur {
|
|
57
|
+
from { filter: blur(4px); }
|
|
58
|
+
to { filter: blur(0); }
|
|
59
|
+
}
|
|
60
|
+
@keyframes rrp-value-reel {
|
|
61
|
+
from { transform: translateY(-1em); opacity: 0; }
|
|
62
|
+
to { transform: translateY(0); opacity: 1; }
|
|
63
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-raffle-picker",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Headless React raffle picker for giveaways, winner draws, countdowns, and slot-machine UIs.",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public",
|
|
8
|
+
"registry": "https://registry.npmjs.org/",
|
|
9
|
+
"provenance": true
|
|
10
|
+
},
|
|
6
11
|
"main": "./dist/index.cjs",
|
|
7
12
|
"module": "./dist/index.mjs",
|
|
8
13
|
"types": "./dist/index.d.mts",
|
|
@@ -16,9 +21,12 @@
|
|
|
16
21
|
"require": "./dist/index.cjs",
|
|
17
22
|
"default": "./dist/index.mjs"
|
|
18
23
|
},
|
|
24
|
+
"./styles.css": "./dist/styles.css",
|
|
19
25
|
"./package.json": "./package.json"
|
|
20
26
|
},
|
|
21
|
-
"sideEffects":
|
|
27
|
+
"sideEffects": [
|
|
28
|
+
"**/*.css"
|
|
29
|
+
],
|
|
22
30
|
"files": [
|
|
23
31
|
"dist"
|
|
24
32
|
],
|
|
@@ -31,7 +39,7 @@
|
|
|
31
39
|
},
|
|
32
40
|
"homepage": "https://react-raffle-one.vercel.app/",
|
|
33
41
|
"scripts": {
|
|
34
|
-
"build": "tsdown",
|
|
42
|
+
"build": "tsdown && cp src/styles.css dist/styles.css",
|
|
35
43
|
"dev": "tsdown --watch",
|
|
36
44
|
"typecheck": "tsc --noEmit",
|
|
37
45
|
"test": "vitest",
|
|
@@ -45,7 +53,7 @@
|
|
|
45
53
|
"build-storybook": "storybook build",
|
|
46
54
|
"changeset": "changeset",
|
|
47
55
|
"version": "changeset version",
|
|
48
|
-
"release": "changeset publish
|
|
56
|
+
"release": "changeset publish"
|
|
49
57
|
},
|
|
50
58
|
"peerDependencies": {
|
|
51
59
|
"react": ">=17",
|
|
@@ -68,9 +76,6 @@
|
|
|
68
76
|
],
|
|
69
77
|
"author": "Kirilinsky (https://github.com/kirilinsky)",
|
|
70
78
|
"license": "MIT",
|
|
71
|
-
"publishConfig": {
|
|
72
|
-
"access": "public"
|
|
73
|
-
},
|
|
74
79
|
"engines": {
|
|
75
80
|
"node": ">=18"
|
|
76
81
|
},
|
|
@@ -107,4 +112,4 @@
|
|
|
107
112
|
"vite": "^8.0.8",
|
|
108
113
|
"vitest": "^4.1.4"
|
|
109
114
|
}
|
|
110
|
-
}
|
|
115
|
+
}
|