react-raffle-picker 0.1.0 → 0.2.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
@@ -3,7 +3,7 @@
3
3
 
4
4
  <h1>react-raffle-picker</h1>
5
5
 
6
- <p><strong>Tiny raffle engine. Big winner energy.</strong></p>
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>
@@ -75,6 +75,21 @@ import { RafflePick } from 'react-raffle-picker'
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)
@@ -89,6 +104,8 @@ Provides context. Renders an optional wrapper element (`as` prop, default `'div'
89
104
  | `random` | `boolean` | `true` | Random pick vs sequential. |
90
105
  | `inertia` | `boolean` | `false` | Soft start / soft stop ramp. |
91
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. |
108
+ | `finalValue` | `number \| string` | — | Forces settle to land on this value. Cycle still appears random; only final freeze is rigged. |
92
109
  | `onSelect` | `(value) => void` | — | Fires once per round on freeze. |
93
110
  | `as` | `ElementType` | `'div'` | Wrapper tag. |
94
111
  | `className` | `string` | — | Wrapper class. |
@@ -166,6 +183,23 @@ Independent multi-reel slot machine. Each reel ticks on its own and stops with a
166
183
  </RafflePick>
167
184
  ```
168
185
 
186
+ ### Rigged draw with predetermined winner
187
+
188
+ `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.
189
+
190
+ ```tsx
191
+ <RafflePick
192
+ items={['Alice', 'Bob', 'Carol']}
193
+ initialValue="Alice"
194
+ finalValue="Bob"
195
+ autoStart={false}
196
+ onSelect={(winner) => console.log(winner)} // always 'Bob'
197
+ >
198
+ <RafflePick.Value animation="reel" />
199
+ <RafflePick.Button startLabel="Draw" stopLabel="Reveal" />
200
+ </RafflePick>
201
+ ```
202
+
169
203
  ### Slot machine with custom result handler
170
204
 
171
205
  ```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 [displayed, setDisplayed] = (0, react.useState)(() => items && items.length > 0 ? items[cycleMin] : cycleMin);
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)(cycleMin);
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
- }, [displayValue, onSelect]));
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,7 @@ function RafflePickRoot({ items, min = 1, max = 100, interval = 100, random = tr
221
257
  cycleInterval,
222
258
  inertia,
223
259
  hasItems,
224
- initialIndex: cycleMin,
260
+ initialIndex,
225
261
  valueRef,
226
262
  displayValue,
227
263
  subscribe,
@@ -235,7 +271,7 @@ function RafflePickRoot({ items, min = 1, max = 100, interval = 100, random = tr
235
271
  cycleInterval,
236
272
  inertia,
237
273
  hasItems,
238
- cycleMin,
274
+ initialIndex,
239
275
  displayValue,
240
276
  subscribe,
241
277
  start,
@@ -388,6 +424,27 @@ function CountdownRunning({ seconds, className, style, children }) {
388
424
  //#endregion
389
425
  //#region src/components/RafflePick/RafflePickSlots.tsx
390
426
  const pickRandom = (pool) => pool[Math.floor(Math.random() * pool.length)] ?? "";
427
+ const SLOT_BASE_CSS = `
428
+ .rrp-slot{display:inline-block;overflow:hidden;height:1em;line-height:1em;vertical-align:baseline}
429
+ .rrp-slot__col{display:flex;flex-direction:column;transform:translateY(-1em);animation:rrp-slot-reel var(--rrp-tick,80ms) linear infinite}
430
+ .rrp-slot__cell{display:block;height:1em;line-height:1em}
431
+ .rrp-slot[data-stopped] .rrp-slot__col{animation:none;transform:translateY(-1em)}
432
+ @keyframes rrp-slot-reel{from{transform:translateY(-1em)}to{transform:translateY(-2em)}}
433
+ `;
434
+ let slotStylesInjected = false;
435
+ const injectSlotStyles = () => {
436
+ if (slotStylesInjected) return;
437
+ if (typeof document === "undefined") return;
438
+ if (document.querySelector("style[data-rrp-slot-base]")) {
439
+ slotStylesInjected = true;
440
+ return;
441
+ }
442
+ const tag = document.createElement("style");
443
+ tag.setAttribute("data-rrp-slot-base", "");
444
+ tag.textContent = SLOT_BASE_CSS;
445
+ document.head.appendChild(tag);
446
+ slotStylesInjected = true;
447
+ };
391
448
  const makeRefs = () => ({
392
449
  root: null,
393
450
  col: null,
@@ -398,6 +455,7 @@ const makeRefs = () => ({
398
455
  stopped: false
399
456
  });
400
457
  function RafflePickSlots({ length = 3, chars = "0123456789", spinInterval = 80, staggerMs = 220, className, slotClassName, style, slotStyle, onResult }) {
458
+ injectSlotStyles();
401
459
  const { phase } = useRaffleContext("RafflePick.Slots");
402
460
  const slotsRef = (0, react.useRef)([]);
403
461
  const stopTimersRef = (0, react.useRef)([]);
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,
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,
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 [displayed, setDisplayed] = useState(() => items && items.length > 0 ? items[cycleMin] : cycleMin);
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(cycleMin);
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
- }, [displayValue, onSelect]));
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,7 @@ function RafflePickRoot({ items, min = 1, max = 100, interval = 100, random = tr
220
256
  cycleInterval,
221
257
  inertia,
222
258
  hasItems,
223
- initialIndex: cycleMin,
259
+ initialIndex,
224
260
  valueRef,
225
261
  displayValue,
226
262
  subscribe,
@@ -234,7 +270,7 @@ function RafflePickRoot({ items, min = 1, max = 100, interval = 100, random = tr
234
270
  cycleInterval,
235
271
  inertia,
236
272
  hasItems,
237
- cycleMin,
273
+ initialIndex,
238
274
  displayValue,
239
275
  subscribe,
240
276
  start,
@@ -387,6 +423,27 @@ function CountdownRunning({ seconds, className, style, children }) {
387
423
  //#endregion
388
424
  //#region src/components/RafflePick/RafflePickSlots.tsx
389
425
  const pickRandom = (pool) => pool[Math.floor(Math.random() * pool.length)] ?? "";
426
+ const SLOT_BASE_CSS = `
427
+ .rrp-slot{display:inline-block;overflow:hidden;height:1em;line-height:1em;vertical-align:baseline}
428
+ .rrp-slot__col{display:flex;flex-direction:column;transform:translateY(-1em);animation:rrp-slot-reel var(--rrp-tick,80ms) linear infinite}
429
+ .rrp-slot__cell{display:block;height:1em;line-height:1em}
430
+ .rrp-slot[data-stopped] .rrp-slot__col{animation:none;transform:translateY(-1em)}
431
+ @keyframes rrp-slot-reel{from{transform:translateY(-1em)}to{transform:translateY(-2em)}}
432
+ `;
433
+ let slotStylesInjected = false;
434
+ const injectSlotStyles = () => {
435
+ if (slotStylesInjected) return;
436
+ if (typeof document === "undefined") return;
437
+ if (document.querySelector("style[data-rrp-slot-base]")) {
438
+ slotStylesInjected = true;
439
+ return;
440
+ }
441
+ const tag = document.createElement("style");
442
+ tag.setAttribute("data-rrp-slot-base", "");
443
+ tag.textContent = SLOT_BASE_CSS;
444
+ document.head.appendChild(tag);
445
+ slotStylesInjected = true;
446
+ };
390
447
  const makeRefs = () => ({
391
448
  root: null,
392
449
  col: null,
@@ -397,6 +454,7 @@ const makeRefs = () => ({
397
454
  stopped: false
398
455
  });
399
456
  function RafflePickSlots({ length = 3, chars = "0123456789", spinInterval = 80, staggerMs = 220, className, slotClassName, style, slotStyle, onResult }) {
457
+ injectSlotStyles();
400
458
  const { phase } = useRaffleContext("RafflePick.Slots");
401
459
  const slotsRef = useRef([]);
402
460
  const stopTimersRef = useRef([]);
@@ -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.0",
3
+ "version": "0.2.0",
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": false,
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 --provenance"
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
+ }