react-raffle-picker 0.2.1 → 0.3.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
@@ -104,14 +104,18 @@ Provides context. Renders an optional wrapper element (`as` prop, default `'div'
104
104
  | `random` | `boolean` | `true` | Random pick vs sequential. |
105
105
  | `inertia` | `boolean` | `false` | Soft start / soft stop ramp. |
106
106
  | `autoStart` | `boolean` | `true` | Begin cycling on mount. |
107
+ | `noRepeat` | `boolean` | `true` | Exclude previously frozen values from later rounds — no duplicate winners across sequential draws in the same mounted instance. Set `false` to allow repeats. |
107
108
  | `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
109
  | `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
110
  | `onSelect` | `(value) => void` | — | Fires once per round on freeze. |
111
+ | `onExhausted` | `() => void` | — | Fires when `start()` is called but `noRepeat` has already drawn every candidate. |
110
112
  | `as` | `ElementType` | `'div'` | Wrapper tag. |
111
113
  | `className` | `string` | — | Wrapper class. |
112
114
  | `style` | `CSSProperties` | — | Wrapper style. |
113
115
  | `children` | `ReactNode` | — | Sub-components. |
114
116
 
117
+ **`noRepeat` in a nutshell:** draw history lives in the mounted `<RafflePick>` instance (not persisted, not synced across instances). `<RafflePick.Button>` auto-disables once the pool is exhausted. For a custom trigger built on `useRaffleContext()`, `start()` becomes a no-op and fires `onExhausted` once the pool is empty. Call `useRaffleContext().resetHistory()` to allow repeats again without unmounting, or change the component's `key` to remount with a clean slate.
118
+
115
119
  ### `<RafflePick.Value>`
116
120
 
117
121
  Renders the cycling value. Updates `textContent` imperatively each tick (no React re-render).
@@ -135,6 +139,7 @@ Toggles start / freeze based on phase. Disabled during settling.
135
139
  | `stopLabel` | `ReactNode` | Shown in `running` / `starting` (click stops). |
136
140
  | `waitLabel` | `ReactNode` | Shown in `settling` (button disabled). |
137
141
  | `children` | `ReactNode` | Fallback label when state-specific label absent. |
142
+ | `disabled` | `boolean` | External disable, on top of the auto-disable during `settling`. |
138
143
  | `className` | `string` | — |
139
144
  | `style` | `CSSProperties` | — |
140
145
 
@@ -202,6 +207,31 @@ Independent multi-reel slot machine. Each reel ticks on its own and stops with a
202
207
  </RafflePick>
203
208
  ```
204
209
 
210
+ ### Multi-round draw without repeat winners
211
+
212
+ `noRepeat` defaults to `true` — each subsequent round in the same mounted
213
+ `<RafflePick>` automatically excludes everyone already drawn. The Button
214
+ disables itself once the pool is empty.
215
+
216
+ ```tsx
217
+ function Giveaway() {
218
+ const [winners, setWinners] = useState<string[]>([])
219
+ return (
220
+ <RafflePick
221
+ items={['Alice', 'Bob', 'Carol', 'Dave']}
222
+ autoStart={false}
223
+ onSelect={(winner) => setWinners((w) => [...w, String(winner)])}
224
+ onExhausted={() => console.log('everyone already won')}
225
+ >
226
+ <RafflePick.Value />
227
+ <RafflePick.Button startLabel="Draw next" stopLabel="Stop" />
228
+ </RafflePick>
229
+ )
230
+ // Click "Draw next" repeatedly — Alice, Bob, Carol, Dave each win once,
231
+ // then the button disables itself. Pass noRepeat={false} to allow repeats.
232
+ }
233
+ ```
234
+
205
235
  ### Slot machine with custom result handler
206
236
 
207
237
  ```tsx
@@ -224,6 +254,18 @@ Independent multi-reel slot machine. Each reel ticks on its own and stops with a
224
254
  - Headless `useRafflePick()` hook for users who want zero rendering from the lib.
225
255
  - Render-prop variant of `<Value>` for fully custom DOM.
226
256
 
257
+ ## Accessibility
258
+
259
+ - `<RafflePick.Value>` cycles are visual-only (`aria-hidden`) — high-frequency tick
260
+ updates are not announced. The frozen result *is* announced once per round via a
261
+ hidden `aria-live="polite"` region.
262
+ - `<RafflePick.Countdown>`'s ring/label are `aria-hidden`; a one-time sr-only
263
+ announcement fires when the countdown starts. The result itself is still
264
+ announced by `<RafflePick.Value>` on freeze.
265
+ - `styles.css` respects `prefers-reduced-motion: reduce` — value animations and the
266
+ slot reel disable their `animation` under that media query. If you ship fully
267
+ custom CSS instead of the bundled stylesheet, add the same guard yourself.
268
+
227
269
  ## Performance notes
228
270
 
229
271
  - Cycle ticks (every `interval` ms) write `textContent` directly via refs — no React render.
package/dist/index.cjs CHANGED
@@ -9,7 +9,7 @@ const getRandom = (min, max) => {
9
9
  };
10
10
  //#endregion
11
11
  //#region src/hooks/useNumberCycle.ts
12
- const useNumberCycle = ({ min, max, interval, random, running, valueRef, onTick }) => {
12
+ const useNumberCycle = ({ min, max, interval, random, running, valueRef, excludedRef, onTick }) => {
13
13
  const onTickRef = (0, react.useRef)(onTick);
14
14
  (0, react.useEffect)(() => {
15
15
  onTickRef.current = onTick;
@@ -18,7 +18,24 @@ const useNumberCycle = ({ min, max, interval, random, running, valueRef, onTick
18
18
  if (!running) return;
19
19
  const id = setInterval(() => {
20
20
  const cur = valueRef.current;
21
- const next = random ? getRandom(min, max) : cur >= max ? min : cur + 1;
21
+ const excluded = excludedRef?.current;
22
+ const total = max - min + 1;
23
+ let next;
24
+ if (excluded && excluded.size > 0 && excluded.size < total) if (random) {
25
+ next = getRandom(min, max);
26
+ let attempts = 0;
27
+ while (excluded.has(next) && attempts < 20) {
28
+ next = getRandom(min, max);
29
+ attempts++;
30
+ }
31
+ while (excluded.has(next)) next = next >= max ? min : next + 1;
32
+ } else {
33
+ next = cur;
34
+ do
35
+ next = next >= max ? min : next + 1;
36
+ while (excluded.has(next));
37
+ }
38
+ else next = random ? getRandom(min, max) : cur >= max ? min : cur + 1;
22
39
  valueRef.current = next;
23
40
  onTickRef.current?.(next);
24
41
  }, interval);
@@ -29,7 +46,8 @@ const useNumberCycle = ({ min, max, interval, random, running, valueRef, onTick
29
46
  min,
30
47
  max,
31
48
  random,
32
- valueRef
49
+ valueRef,
50
+ excludedRef
33
51
  ]);
34
52
  return valueRef;
35
53
  };
@@ -172,11 +190,12 @@ const useRaffleContext = (componentName) => {
172
190
  };
173
191
  //#endregion
174
192
  //#region src/components/RafflePick/RafflePick.tsx
175
- function RafflePickRoot({ items, min = 1, max = 100, interval = 100, random = true, inertia = false, autoStart = true, initialValue, finalValue, onSelect, as = "div", className, style, children }) {
193
+ function RafflePickRoot({ items, min = 1, max = 100, interval = 100, random = true, inertia = false, autoStart = true, noRepeat = true, initialValue, finalValue, onSelect, onExhausted, as = "div", className, style, children }) {
176
194
  const itemCount = items?.length ?? 0;
177
195
  const hasItems = itemCount > 0;
178
196
  const cycleMin = hasItems ? 0 : min;
179
197
  const cycleMax = hasItems ? itemCount - 1 : max;
198
+ const totalCandidates = cycleMax - cycleMin + 1;
180
199
  const initialPhase = autoStart ? inertia ? "starting" : "running" : "idle";
181
200
  const itemsRef = (0, react.useRef)(items);
182
201
  (0, react.useEffect)(() => {
@@ -195,14 +214,18 @@ function RafflePickRoot({ items, min = 1, max = 100, interval = 100, random = tr
195
214
  }
196
215
  return typeof value === "number" ? value : void 0;
197
216
  }, []);
198
- const initialIndex = (() => {
217
+ const initialIndex = (0, react.useMemo)(() => {
199
218
  if (initialValue === void 0) return cycleMin;
200
219
  if (items && items.length > 0) {
201
220
  const i = items.indexOf(String(initialValue));
202
221
  return i >= 0 ? i : cycleMin;
203
222
  }
204
223
  return typeof initialValue === "number" ? initialValue : cycleMin;
205
- })();
224
+ }, [
225
+ initialValue,
226
+ items,
227
+ cycleMin
228
+ ]);
206
229
  const [displayed, setDisplayed] = (0, react.useState)(() => {
207
230
  if (initialValue !== void 0) {
208
231
  const its = items;
@@ -228,17 +251,49 @@ function RafflePickRoot({ items, min = 1, max = 100, interval = 100, random = tr
228
251
  (0, react.useEffect)(() => {
229
252
  finalValueRef.current = finalValue;
230
253
  }, [finalValue]);
254
+ const drawnRef = (0, react.useRef)(/* @__PURE__ */ new Set());
255
+ const [drawnCount, setDrawnCount] = (0, react.useState)(0);
256
+ const onExhaustedRef = (0, react.useRef)(onExhausted);
257
+ (0, react.useEffect)(() => {
258
+ onExhaustedRef.current = onExhausted;
259
+ }, [onExhausted]);
231
260
  const { phase, step, start, freeze, reset } = useRafflePhase(inertia, initialPhase, (0, react.useCallback)(() => {
232
261
  const forced = valueToIndex(finalValueRef.current);
233
262
  if (forced !== void 0) valueRef.current = forced;
263
+ if (noRepeat) {
264
+ drawnRef.current.add(valueRef.current);
265
+ setDrawnCount(drawnRef.current.size);
266
+ }
234
267
  const v = displayValue(valueRef.current);
235
268
  setDisplayed(v);
236
269
  onSelect?.(v);
237
270
  }, [
238
271
  displayValue,
239
272
  onSelect,
240
- valueToIndex
273
+ valueToIndex,
274
+ noRepeat
241
275
  ]));
276
+ const exhausted = noRepeat && drawnCount >= totalCandidates;
277
+ const guardedStart = (0, react.useCallback)(() => {
278
+ if (noRepeat && drawnRef.current.size >= totalCandidates) {
279
+ onExhaustedRef.current?.();
280
+ return;
281
+ }
282
+ start();
283
+ }, [
284
+ start,
285
+ noRepeat,
286
+ totalCandidates
287
+ ]);
288
+ const resetHistory = (0, react.useCallback)(() => {
289
+ drawnRef.current.clear();
290
+ setDrawnCount(0);
291
+ }, []);
292
+ (0, react.useEffect)(() => {
293
+ if (phase !== "idle") return;
294
+ valueRef.current = initialIndex;
295
+ }, [phase, initialIndex]);
296
+ const displayedValue = phase === "idle" ? items && items.length > 0 ? items[initialIndex] ?? items[cycleMin] : initialIndex : displayed;
242
297
  const multiplier = getInertiaMultiplier(phase, step, inertia);
243
298
  const cycleInterval = Math.round(Math.max(50, interval) * multiplier);
244
299
  useNumberCycle({
@@ -248,39 +303,49 @@ function RafflePickRoot({ items, min = 1, max = 100, interval = 100, random = tr
248
303
  random,
249
304
  running: phase === "starting" || phase === "running" || phase === "settling",
250
305
  valueRef,
306
+ excludedRef: noRepeat ? drawnRef : void 0,
251
307
  onTick
252
308
  });
309
+ const remaining = noRepeat ? Math.max(0, totalCandidates - drawnCount) : totalCandidates;
253
310
  const ctxValue = (0, react.useMemo)(() => ({
254
311
  phase,
255
312
  step,
256
- displayed,
313
+ displayed: displayedValue,
257
314
  cycleInterval,
258
315
  inertia,
259
316
  hasItems,
260
317
  initialIndex,
261
318
  initialValue,
262
319
  finalValue,
320
+ noRepeat,
321
+ exhausted,
322
+ remaining,
263
323
  valueRef,
264
324
  displayValue,
265
325
  subscribe,
266
- start,
326
+ start: guardedStart,
267
327
  freeze,
268
- reset
328
+ reset,
329
+ resetHistory
269
330
  }), [
270
331
  phase,
271
332
  step,
272
- displayed,
333
+ displayedValue,
273
334
  cycleInterval,
274
335
  inertia,
275
336
  hasItems,
276
337
  initialIndex,
277
338
  initialValue,
278
339
  finalValue,
340
+ noRepeat,
341
+ exhausted,
342
+ remaining,
279
343
  displayValue,
280
344
  subscribe,
281
- start,
345
+ guardedStart,
282
346
  freeze,
283
- reset
347
+ reset,
348
+ resetHistory
284
349
  ]);
285
350
  const selectionState = phase === "idle" ? "idle" : phase === "frozen" ? "frozen" : "running";
286
351
  return (0, react.createElement)(as, {
@@ -297,6 +362,17 @@ function RafflePickRoot({ items, min = 1, max = 100, interval = 100, random = tr
297
362
  }
298
363
  //#endregion
299
364
  //#region src/components/RafflePick/RafflePickValue.tsx
365
+ const SR_ONLY_STYLE = {
366
+ position: "absolute",
367
+ width: 1,
368
+ height: 1,
369
+ padding: 0,
370
+ margin: -1,
371
+ overflow: "hidden",
372
+ clip: "rect(0, 0, 0, 0)",
373
+ whiteSpace: "nowrap",
374
+ border: 0
375
+ };
300
376
  function RafflePickValue({ animation = "roll", className, style, as = "span" }) {
301
377
  const { phase, step, displayed, cycleInterval, valueRef, displayValue, subscribe } = useRaffleContext("RafflePick.Value");
302
378
  const nodeRef = (0, react.useRef)(null);
@@ -316,7 +392,7 @@ function RafflePickValue({ animation = "roll", className, style, as = "span" })
316
392
  (0, react.useLayoutEffect)(() => {
317
393
  if (running) writeNode(valueRef.current);
318
394
  });
319
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(as, {
395
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(as, {
320
396
  ref: nodeRef,
321
397
  className: (0, react.useMemo)(() => joinClassNames("rrp-value", className), [className]),
322
398
  "data-animation": animation,
@@ -327,16 +403,23 @@ function RafflePickValue({ animation = "roll", className, style, as = "span" })
327
403
  ...style,
328
404
  ["--rrp-tick"]: `${cycleInterval}ms`
329
405
  }), [style, cycleInterval]),
406
+ "aria-hidden": "true",
330
407
  children: displayed
331
- });
408
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
409
+ role: "status",
410
+ "aria-live": "polite",
411
+ style: SR_ONLY_STYLE,
412
+ children: phase === "frozen" ? String(displayed) : ""
413
+ })] });
332
414
  }
333
415
  //#endregion
334
416
  //#region src/components/RafflePick/RafflePickButton.tsx
335
- function RafflePickButton({ className, style, children, startLabel, stopLabel, waitLabel }) {
336
- const { phase, start, freeze, reset } = useRaffleContext("RafflePick.Button");
417
+ function RafflePickButton({ className, style, children, startLabel, stopLabel, waitLabel, disabled }) {
418
+ const { phase, exhausted, start, freeze, reset } = useRaffleContext("RafflePick.Button");
337
419
  const running = phase === "starting" || phase === "running" || phase === "settling";
420
+ const isDisabled = disabled || phase === "settling" || exhausted;
338
421
  const handleClick = (0, react.useCallback)(() => {
339
- if (phase === "settling") return;
422
+ if (isDisabled) return;
340
423
  if (running) {
341
424
  freeze();
342
425
  return;
@@ -344,7 +427,7 @@ function RafflePickButton({ className, style, children, startLabel, stopLabel, w
344
427
  reset();
345
428
  start();
346
429
  }, [
347
- phase,
430
+ isDisabled,
348
431
  running,
349
432
  freeze,
350
433
  reset,
@@ -358,7 +441,7 @@ function RafflePickButton({ className, style, children, startLabel, stopLabel, w
358
441
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
359
442
  className: cls,
360
443
  style,
361
- disabled: phase === "settling",
444
+ disabled: isDisabled,
362
445
  onClick: handleClick,
363
446
  "data-phase": phase,
364
447
  children: label
@@ -395,33 +478,52 @@ function CountdownRunning({ seconds, className, style, children }) {
395
478
  ...style,
396
479
  ["--rrp-countdown"]: `${seconds}s`
397
480
  }), [style, seconds]);
398
- if (children) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
481
+ const srAnnouncement = /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
482
+ role: "status",
483
+ style: {
484
+ position: "absolute",
485
+ width: 1,
486
+ height: 1,
487
+ padding: 0,
488
+ margin: -1,
489
+ overflow: "hidden",
490
+ clip: "rect(0, 0, 0, 0)",
491
+ whiteSpace: "nowrap",
492
+ border: 0
493
+ },
494
+ children: `${seconds}-second countdown started`
495
+ });
496
+ if (children) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
399
497
  className: cls,
400
498
  style: mergedStyle,
401
- "aria-hidden": "true",
402
- children: children(remaining)
499
+ children: [srAnnouncement, /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
500
+ "aria-hidden": "true",
501
+ children: children(remaining)
502
+ })]
403
503
  });
404
504
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
405
505
  className: cls,
406
506
  style: mergedStyle,
407
- "aria-hidden": "true",
408
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
409
- className: "rrp-countdown__svg",
410
- viewBox: "0 0 36 36",
411
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
412
- className: "rrp-countdown__track",
413
- cx: "18",
414
- cy: "18",
415
- r: "16"
416
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
417
- className: "rrp-countdown__bar",
418
- cx: "18",
419
- cy: "18",
420
- r: "16"
507
+ children: [srAnnouncement, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
508
+ "aria-hidden": "true",
509
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
510
+ className: "rrp-countdown__svg",
511
+ viewBox: "0 0 36 36",
512
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
513
+ className: "rrp-countdown__track",
514
+ cx: "18",
515
+ cy: "18",
516
+ r: "16"
517
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
518
+ className: "rrp-countdown__bar",
519
+ cx: "18",
520
+ cy: "18",
521
+ r: "16"
522
+ })]
523
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
524
+ className: "rrp-countdown__label",
525
+ children: remaining
421
526
  })]
422
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
423
- className: "rrp-countdown__label",
424
- children: remaining
425
527
  })]
426
528
  });
427
529
  }
@@ -429,25 +531,26 @@ function CountdownRunning({ seconds, className, style, children }) {
429
531
  //#region src/components/RafflePick/RafflePickSlots.tsx
430
532
  const pickRandom = (pool) => pool[Math.floor(Math.random() * pool.length)] ?? "";
431
533
  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)}}
534
+ .rrp-slot{display:inline-block;position:relative;overflow:hidden;vertical-align:baseline}
535
+ .rrp-slot__col{position:absolute;top:0;left:0;right:0;height:300%;transform:translate3d(0,-33.3333%,0);animation:rrp-slot-reel var(--rrp-tick,80ms) linear infinite;will-change:transform}
536
+ .rrp-slot__cell{height:33.3333%;display:grid;place-items:center;padding:0;margin:0;box-sizing:border-box;text-align:center}
537
+ .rrp-slot[data-stopped] .rrp-slot__col{animation:none;transform:translate3d(0,-33.3333%,0)}
538
+ @keyframes rrp-slot-reel{from{transform:translate3d(0,-33.3333%,0)}to{transform:translate3d(0,-66.6666%,0)}}
437
539
  `;
438
- let slotStylesInjected = false;
540
+ const SLOT_STYLES_VERSION = "4";
439
541
  const injectSlotStyles = () => {
440
- if (slotStylesInjected) return;
441
542
  if (typeof document === "undefined") return;
442
- if (document.querySelector("style[data-rrp-slot-base]")) {
443
- slotStylesInjected = true;
543
+ const existing = document.querySelector("style[data-rrp-slot-base]");
544
+ if (existing) {
545
+ if (existing.getAttribute("data-rrp-slot-base") === SLOT_STYLES_VERSION) return;
546
+ existing.textContent = SLOT_BASE_CSS;
547
+ existing.setAttribute("data-rrp-slot-base", SLOT_STYLES_VERSION);
444
548
  return;
445
549
  }
446
550
  const tag = document.createElement("style");
447
- tag.setAttribute("data-rrp-slot-base", "");
551
+ tag.setAttribute("data-rrp-slot-base", SLOT_STYLES_VERSION);
448
552
  tag.textContent = SLOT_BASE_CSS;
449
553
  document.head.appendChild(tag);
450
- slotStylesInjected = true;
451
554
  };
452
555
  const makeRefs = () => ({
453
556
  root: null,
@@ -459,7 +562,7 @@ const makeRefs = () => ({
459
562
  stopped: false
460
563
  });
461
564
  function RafflePickSlots({ length = 3, chars = "0123456789", spinInterval = 80, staggerMs = 220, className, slotClassName, style, slotStyle, onResult }) {
462
- injectSlotStyles();
565
+ (0, react.useEffect)(injectSlotStyles, []);
463
566
  const { phase, initialValue, finalValue } = useRaffleContext("RafflePick.Slots");
464
567
  const initialChars = (0, react.useMemo)(() => {
465
568
  const seed = typeof initialValue === "string" ? initialValue : "";
package/dist/index.d.cts CHANGED
@@ -6,21 +6,48 @@ import * as _$react_jsx_runtime0 from "react/jsx-runtime";
6
6
  type AnimationType = 'roll' | 'fade' | 'blur' | 'reel';
7
7
  type RafflePickValue$1 = number | string;
8
8
  interface RafflePickRootProps {
9
+ /**
10
+ * Item pool to cycle through (names, tickets, anything). Switches the
11
+ * component to "items mode" — `min`/`max` are ignored. An empty array
12
+ * (`[]`, as opposed to `undefined`) is treated as "no items" and falls
13
+ * back to the numeric `min`/`max` range.
14
+ */
9
15
  items?: string[];
16
+ /** Range start in numeric mode. Ignored when `items` is set. */
10
17
  min?: number;
18
+ /** Range end in numeric mode. Ignored when `items` is set. */
11
19
  max?: number;
20
+ /** Tick speed in ms. Clamped to a minimum of 50. */
12
21
  interval?: number;
22
+ /** Pick the next tick value at random instead of incrementing sequentially. */
13
23
  random?: boolean;
24
+ /** Soft-start / soft-stop ramp (`starting`/`settling` phases) instead of an instant flip. */
14
25
  inertia?: boolean;
26
+ /** Begin cycling on mount. Set `false` to wait for `<RafflePick.Button>`/`start()`. */
15
27
  autoStart?: boolean;
28
+ /**
29
+ * Exclude previously frozen values from future rounds within this mounted
30
+ * instance — no duplicate winners across sequential draws. Default `true`.
31
+ * Set `false` to allow the same value/entry to be picked again. History is
32
+ * cleared on unmount (or via `resetHistory()` from `useRaffleContext()`) —
33
+ * remount with a new `key` for a fresh no-repeat pool.
34
+ */
35
+ noRepeat?: boolean;
16
36
  /** Value shown before first run. Number in min/max mode, string in items mode. */
17
37
  initialValue?: RafflePickValue$1;
18
38
  /** When set, settle always lands on this value while cycle still appears random. */
19
39
  finalValue?: RafflePickValue$1;
40
+ /** Fires once per round, when the phase settles to `frozen`. */
20
41
  onSelect?: (value: RafflePickValue$1) => void;
42
+ /** Fires when `start()`/`<RafflePick.Button>` is used but `noRepeat` has exhausted the pool. */
43
+ onExhausted?: () => void;
44
+ /** Wrapper element/component. Default `'div'`. */
21
45
  as?: ElementType;
46
+ /** Wrapper class. */
22
47
  className?: string;
48
+ /** Wrapper inline style. */
23
49
  style?: CSSProperties;
50
+ /** Compound sub-components: `<RafflePick.Value>`, `.Button`, `.Countdown`, `.Slots`. */
24
51
  children?: ReactNode;
25
52
  }
26
53
  interface RafflePickValueProps {
@@ -40,6 +67,8 @@ interface RafflePickButtonProps {
40
67
  stopLabel?: ReactNode;
41
68
  /** Label while settling (button disabled). */
42
69
  waitLabel?: ReactNode;
70
+ /** External disable (e.g. form not valid). Always disabled while settling regardless. */
71
+ disabled?: boolean;
43
72
  }
44
73
  interface RafflePickSlotsProps {
45
74
  /** Number of slots. */
@@ -75,9 +104,11 @@ declare function RafflePickRoot({
75
104
  random,
76
105
  inertia,
77
106
  autoStart,
107
+ noRepeat,
78
108
  initialValue,
79
109
  finalValue,
80
110
  onSelect,
111
+ onExhausted,
81
112
  as,
82
113
  className,
83
114
  style,
@@ -99,7 +130,8 @@ declare function RafflePickButton({
99
130
  children,
100
131
  startLabel,
101
132
  stopLabel,
102
- waitLabel
133
+ waitLabel,
134
+ disabled
103
135
  }: RafflePickButtonProps): _$react_jsx_runtime0.JSX.Element;
104
136
  //#endregion
105
137
  //#region src/components/RafflePick/RafflePickCountdown.d.ts
@@ -137,12 +169,20 @@ interface RaffleContextValue {
137
169
  initialIndex: number;
138
170
  initialValue?: RafflePickValue$1;
139
171
  finalValue?: RafflePickValue$1;
172
+ /** Whether `noRepeat` is enabled for this round. */
173
+ noRepeat: boolean;
174
+ /** `noRepeat` pool has no candidates left — `start()` is a no-op until `resetHistory()`. */
175
+ exhausted: boolean;
176
+ /** Candidates left to draw. Equals the full pool size when `noRepeat` is off. */
177
+ remaining: number;
140
178
  valueRef: RefObject<number>;
141
179
  displayValue: (index: number) => RafflePickValue$1;
142
180
  subscribe: (fn: (value: number) => void) => () => void;
143
181
  start: () => void;
144
182
  freeze: () => void;
145
183
  reset: () => void;
184
+ /** Clears the `noRepeat` history so previously drawn values can appear again. */
185
+ resetHistory: () => void;
146
186
  }
147
187
  declare const RaffleContext: _$react.Context<RaffleContextValue | null>;
148
188
  declare const useRaffleContext: (componentName: string) => RaffleContextValue;
@@ -156,4 +196,4 @@ type RafflePickCompound = typeof RafflePickRoot & {
156
196
  };
157
197
  declare const RafflePick: RafflePickCompound;
158
198
  //#endregion
159
- 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 };
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 };
package/dist/index.d.mts CHANGED
@@ -6,21 +6,48 @@ import * as _$react_jsx_runtime0 from "react/jsx-runtime";
6
6
  type AnimationType = 'roll' | 'fade' | 'blur' | 'reel';
7
7
  type RafflePickValue$1 = number | string;
8
8
  interface RafflePickRootProps {
9
+ /**
10
+ * Item pool to cycle through (names, tickets, anything). Switches the
11
+ * component to "items mode" — `min`/`max` are ignored. An empty array
12
+ * (`[]`, as opposed to `undefined`) is treated as "no items" and falls
13
+ * back to the numeric `min`/`max` range.
14
+ */
9
15
  items?: string[];
16
+ /** Range start in numeric mode. Ignored when `items` is set. */
10
17
  min?: number;
18
+ /** Range end in numeric mode. Ignored when `items` is set. */
11
19
  max?: number;
20
+ /** Tick speed in ms. Clamped to a minimum of 50. */
12
21
  interval?: number;
22
+ /** Pick the next tick value at random instead of incrementing sequentially. */
13
23
  random?: boolean;
24
+ /** Soft-start / soft-stop ramp (`starting`/`settling` phases) instead of an instant flip. */
14
25
  inertia?: boolean;
26
+ /** Begin cycling on mount. Set `false` to wait for `<RafflePick.Button>`/`start()`. */
15
27
  autoStart?: boolean;
28
+ /**
29
+ * Exclude previously frozen values from future rounds within this mounted
30
+ * instance — no duplicate winners across sequential draws. Default `true`.
31
+ * Set `false` to allow the same value/entry to be picked again. History is
32
+ * cleared on unmount (or via `resetHistory()` from `useRaffleContext()`) —
33
+ * remount with a new `key` for a fresh no-repeat pool.
34
+ */
35
+ noRepeat?: boolean;
16
36
  /** Value shown before first run. Number in min/max mode, string in items mode. */
17
37
  initialValue?: RafflePickValue$1;
18
38
  /** When set, settle always lands on this value while cycle still appears random. */
19
39
  finalValue?: RafflePickValue$1;
40
+ /** Fires once per round, when the phase settles to `frozen`. */
20
41
  onSelect?: (value: RafflePickValue$1) => void;
42
+ /** Fires when `start()`/`<RafflePick.Button>` is used but `noRepeat` has exhausted the pool. */
43
+ onExhausted?: () => void;
44
+ /** Wrapper element/component. Default `'div'`. */
21
45
  as?: ElementType;
46
+ /** Wrapper class. */
22
47
  className?: string;
48
+ /** Wrapper inline style. */
23
49
  style?: CSSProperties;
50
+ /** Compound sub-components: `<RafflePick.Value>`, `.Button`, `.Countdown`, `.Slots`. */
24
51
  children?: ReactNode;
25
52
  }
26
53
  interface RafflePickValueProps {
@@ -40,6 +67,8 @@ interface RafflePickButtonProps {
40
67
  stopLabel?: ReactNode;
41
68
  /** Label while settling (button disabled). */
42
69
  waitLabel?: ReactNode;
70
+ /** External disable (e.g. form not valid). Always disabled while settling regardless. */
71
+ disabled?: boolean;
43
72
  }
44
73
  interface RafflePickSlotsProps {
45
74
  /** Number of slots. */
@@ -75,9 +104,11 @@ declare function RafflePickRoot({
75
104
  random,
76
105
  inertia,
77
106
  autoStart,
107
+ noRepeat,
78
108
  initialValue,
79
109
  finalValue,
80
110
  onSelect,
111
+ onExhausted,
81
112
  as,
82
113
  className,
83
114
  style,
@@ -99,7 +130,8 @@ declare function RafflePickButton({
99
130
  children,
100
131
  startLabel,
101
132
  stopLabel,
102
- waitLabel
133
+ waitLabel,
134
+ disabled
103
135
  }: RafflePickButtonProps): _$react_jsx_runtime0.JSX.Element;
104
136
  //#endregion
105
137
  //#region src/components/RafflePick/RafflePickCountdown.d.ts
@@ -137,12 +169,20 @@ interface RaffleContextValue {
137
169
  initialIndex: number;
138
170
  initialValue?: RafflePickValue$1;
139
171
  finalValue?: RafflePickValue$1;
172
+ /** Whether `noRepeat` is enabled for this round. */
173
+ noRepeat: boolean;
174
+ /** `noRepeat` pool has no candidates left — `start()` is a no-op until `resetHistory()`. */
175
+ exhausted: boolean;
176
+ /** Candidates left to draw. Equals the full pool size when `noRepeat` is off. */
177
+ remaining: number;
140
178
  valueRef: RefObject<number>;
141
179
  displayValue: (index: number) => RafflePickValue$1;
142
180
  subscribe: (fn: (value: number) => void) => () => void;
143
181
  start: () => void;
144
182
  freeze: () => void;
145
183
  reset: () => void;
184
+ /** Clears the `noRepeat` history so previously drawn values can appear again. */
185
+ resetHistory: () => void;
146
186
  }
147
187
  declare const RaffleContext: _$react.Context<RaffleContextValue | null>;
148
188
  declare const useRaffleContext: (componentName: string) => RaffleContextValue;
@@ -156,4 +196,4 @@ type RafflePickCompound = typeof RafflePickRoot & {
156
196
  };
157
197
  declare const RafflePick: RafflePickCompound;
158
198
  //#endregion
159
- 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 };
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 };
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createContext, createElement, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState } from "react";
2
- import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  //#region src/utils/get-random.ts
4
4
  const getRandom = (min, max) => {
5
5
  min = Math.ceil(min);
@@ -8,7 +8,7 @@ const getRandom = (min, max) => {
8
8
  };
9
9
  //#endregion
10
10
  //#region src/hooks/useNumberCycle.ts
11
- const useNumberCycle = ({ min, max, interval, random, running, valueRef, onTick }) => {
11
+ const useNumberCycle = ({ min, max, interval, random, running, valueRef, excludedRef, onTick }) => {
12
12
  const onTickRef = useRef(onTick);
13
13
  useEffect(() => {
14
14
  onTickRef.current = onTick;
@@ -17,7 +17,24 @@ const useNumberCycle = ({ min, max, interval, random, running, valueRef, onTick
17
17
  if (!running) return;
18
18
  const id = setInterval(() => {
19
19
  const cur = valueRef.current;
20
- const next = random ? getRandom(min, max) : cur >= max ? min : cur + 1;
20
+ const excluded = excludedRef?.current;
21
+ const total = max - min + 1;
22
+ let next;
23
+ if (excluded && excluded.size > 0 && excluded.size < total) if (random) {
24
+ next = getRandom(min, max);
25
+ let attempts = 0;
26
+ while (excluded.has(next) && attempts < 20) {
27
+ next = getRandom(min, max);
28
+ attempts++;
29
+ }
30
+ while (excluded.has(next)) next = next >= max ? min : next + 1;
31
+ } else {
32
+ next = cur;
33
+ do
34
+ next = next >= max ? min : next + 1;
35
+ while (excluded.has(next));
36
+ }
37
+ else next = random ? getRandom(min, max) : cur >= max ? min : cur + 1;
21
38
  valueRef.current = next;
22
39
  onTickRef.current?.(next);
23
40
  }, interval);
@@ -28,7 +45,8 @@ const useNumberCycle = ({ min, max, interval, random, running, valueRef, onTick
28
45
  min,
29
46
  max,
30
47
  random,
31
- valueRef
48
+ valueRef,
49
+ excludedRef
32
50
  ]);
33
51
  return valueRef;
34
52
  };
@@ -171,11 +189,12 @@ const useRaffleContext = (componentName) => {
171
189
  };
172
190
  //#endregion
173
191
  //#region src/components/RafflePick/RafflePick.tsx
174
- function RafflePickRoot({ items, min = 1, max = 100, interval = 100, random = true, inertia = false, autoStart = true, initialValue, finalValue, onSelect, as = "div", className, style, children }) {
192
+ function RafflePickRoot({ items, min = 1, max = 100, interval = 100, random = true, inertia = false, autoStart = true, noRepeat = true, initialValue, finalValue, onSelect, onExhausted, as = "div", className, style, children }) {
175
193
  const itemCount = items?.length ?? 0;
176
194
  const hasItems = itemCount > 0;
177
195
  const cycleMin = hasItems ? 0 : min;
178
196
  const cycleMax = hasItems ? itemCount - 1 : max;
197
+ const totalCandidates = cycleMax - cycleMin + 1;
179
198
  const initialPhase = autoStart ? inertia ? "starting" : "running" : "idle";
180
199
  const itemsRef = useRef(items);
181
200
  useEffect(() => {
@@ -194,14 +213,18 @@ function RafflePickRoot({ items, min = 1, max = 100, interval = 100, random = tr
194
213
  }
195
214
  return typeof value === "number" ? value : void 0;
196
215
  }, []);
197
- const initialIndex = (() => {
216
+ const initialIndex = useMemo(() => {
198
217
  if (initialValue === void 0) return cycleMin;
199
218
  if (items && items.length > 0) {
200
219
  const i = items.indexOf(String(initialValue));
201
220
  return i >= 0 ? i : cycleMin;
202
221
  }
203
222
  return typeof initialValue === "number" ? initialValue : cycleMin;
204
- })();
223
+ }, [
224
+ initialValue,
225
+ items,
226
+ cycleMin
227
+ ]);
205
228
  const [displayed, setDisplayed] = useState(() => {
206
229
  if (initialValue !== void 0) {
207
230
  const its = items;
@@ -227,17 +250,49 @@ function RafflePickRoot({ items, min = 1, max = 100, interval = 100, random = tr
227
250
  useEffect(() => {
228
251
  finalValueRef.current = finalValue;
229
252
  }, [finalValue]);
253
+ const drawnRef = useRef(/* @__PURE__ */ new Set());
254
+ const [drawnCount, setDrawnCount] = useState(0);
255
+ const onExhaustedRef = useRef(onExhausted);
256
+ useEffect(() => {
257
+ onExhaustedRef.current = onExhausted;
258
+ }, [onExhausted]);
230
259
  const { phase, step, start, freeze, reset } = useRafflePhase(inertia, initialPhase, useCallback(() => {
231
260
  const forced = valueToIndex(finalValueRef.current);
232
261
  if (forced !== void 0) valueRef.current = forced;
262
+ if (noRepeat) {
263
+ drawnRef.current.add(valueRef.current);
264
+ setDrawnCount(drawnRef.current.size);
265
+ }
233
266
  const v = displayValue(valueRef.current);
234
267
  setDisplayed(v);
235
268
  onSelect?.(v);
236
269
  }, [
237
270
  displayValue,
238
271
  onSelect,
239
- valueToIndex
272
+ valueToIndex,
273
+ noRepeat
240
274
  ]));
275
+ const exhausted = noRepeat && drawnCount >= totalCandidates;
276
+ const guardedStart = useCallback(() => {
277
+ if (noRepeat && drawnRef.current.size >= totalCandidates) {
278
+ onExhaustedRef.current?.();
279
+ return;
280
+ }
281
+ start();
282
+ }, [
283
+ start,
284
+ noRepeat,
285
+ totalCandidates
286
+ ]);
287
+ const resetHistory = useCallback(() => {
288
+ drawnRef.current.clear();
289
+ setDrawnCount(0);
290
+ }, []);
291
+ useEffect(() => {
292
+ if (phase !== "idle") return;
293
+ valueRef.current = initialIndex;
294
+ }, [phase, initialIndex]);
295
+ const displayedValue = phase === "idle" ? items && items.length > 0 ? items[initialIndex] ?? items[cycleMin] : initialIndex : displayed;
241
296
  const multiplier = getInertiaMultiplier(phase, step, inertia);
242
297
  const cycleInterval = Math.round(Math.max(50, interval) * multiplier);
243
298
  useNumberCycle({
@@ -247,39 +302,49 @@ function RafflePickRoot({ items, min = 1, max = 100, interval = 100, random = tr
247
302
  random,
248
303
  running: phase === "starting" || phase === "running" || phase === "settling",
249
304
  valueRef,
305
+ excludedRef: noRepeat ? drawnRef : void 0,
250
306
  onTick
251
307
  });
308
+ const remaining = noRepeat ? Math.max(0, totalCandidates - drawnCount) : totalCandidates;
252
309
  const ctxValue = useMemo(() => ({
253
310
  phase,
254
311
  step,
255
- displayed,
312
+ displayed: displayedValue,
256
313
  cycleInterval,
257
314
  inertia,
258
315
  hasItems,
259
316
  initialIndex,
260
317
  initialValue,
261
318
  finalValue,
319
+ noRepeat,
320
+ exhausted,
321
+ remaining,
262
322
  valueRef,
263
323
  displayValue,
264
324
  subscribe,
265
- start,
325
+ start: guardedStart,
266
326
  freeze,
267
- reset
327
+ reset,
328
+ resetHistory
268
329
  }), [
269
330
  phase,
270
331
  step,
271
- displayed,
332
+ displayedValue,
272
333
  cycleInterval,
273
334
  inertia,
274
335
  hasItems,
275
336
  initialIndex,
276
337
  initialValue,
277
338
  finalValue,
339
+ noRepeat,
340
+ exhausted,
341
+ remaining,
278
342
  displayValue,
279
343
  subscribe,
280
- start,
344
+ guardedStart,
281
345
  freeze,
282
- reset
346
+ reset,
347
+ resetHistory
283
348
  ]);
284
349
  const selectionState = phase === "idle" ? "idle" : phase === "frozen" ? "frozen" : "running";
285
350
  return createElement(as, {
@@ -296,6 +361,17 @@ function RafflePickRoot({ items, min = 1, max = 100, interval = 100, random = tr
296
361
  }
297
362
  //#endregion
298
363
  //#region src/components/RafflePick/RafflePickValue.tsx
364
+ const SR_ONLY_STYLE = {
365
+ position: "absolute",
366
+ width: 1,
367
+ height: 1,
368
+ padding: 0,
369
+ margin: -1,
370
+ overflow: "hidden",
371
+ clip: "rect(0, 0, 0, 0)",
372
+ whiteSpace: "nowrap",
373
+ border: 0
374
+ };
299
375
  function RafflePickValue({ animation = "roll", className, style, as = "span" }) {
300
376
  const { phase, step, displayed, cycleInterval, valueRef, displayValue, subscribe } = useRaffleContext("RafflePick.Value");
301
377
  const nodeRef = useRef(null);
@@ -315,7 +391,7 @@ function RafflePickValue({ animation = "roll", className, style, as = "span" })
315
391
  useLayoutEffect(() => {
316
392
  if (running) writeNode(valueRef.current);
317
393
  });
318
- return /* @__PURE__ */ jsx(as, {
394
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(as, {
319
395
  ref: nodeRef,
320
396
  className: useMemo(() => joinClassNames("rrp-value", className), [className]),
321
397
  "data-animation": animation,
@@ -326,16 +402,23 @@ function RafflePickValue({ animation = "roll", className, style, as = "span" })
326
402
  ...style,
327
403
  ["--rrp-tick"]: `${cycleInterval}ms`
328
404
  }), [style, cycleInterval]),
405
+ "aria-hidden": "true",
329
406
  children: displayed
330
- });
407
+ }), /* @__PURE__ */ jsx("span", {
408
+ role: "status",
409
+ "aria-live": "polite",
410
+ style: SR_ONLY_STYLE,
411
+ children: phase === "frozen" ? String(displayed) : ""
412
+ })] });
331
413
  }
332
414
  //#endregion
333
415
  //#region src/components/RafflePick/RafflePickButton.tsx
334
- function RafflePickButton({ className, style, children, startLabel, stopLabel, waitLabel }) {
335
- const { phase, start, freeze, reset } = useRaffleContext("RafflePick.Button");
416
+ function RafflePickButton({ className, style, children, startLabel, stopLabel, waitLabel, disabled }) {
417
+ const { phase, exhausted, start, freeze, reset } = useRaffleContext("RafflePick.Button");
336
418
  const running = phase === "starting" || phase === "running" || phase === "settling";
419
+ const isDisabled = disabled || phase === "settling" || exhausted;
337
420
  const handleClick = useCallback(() => {
338
- if (phase === "settling") return;
421
+ if (isDisabled) return;
339
422
  if (running) {
340
423
  freeze();
341
424
  return;
@@ -343,7 +426,7 @@ function RafflePickButton({ className, style, children, startLabel, stopLabel, w
343
426
  reset();
344
427
  start();
345
428
  }, [
346
- phase,
429
+ isDisabled,
347
430
  running,
348
431
  freeze,
349
432
  reset,
@@ -357,7 +440,7 @@ function RafflePickButton({ className, style, children, startLabel, stopLabel, w
357
440
  return /* @__PURE__ */ jsx("button", {
358
441
  className: cls,
359
442
  style,
360
- disabled: phase === "settling",
443
+ disabled: isDisabled,
361
444
  onClick: handleClick,
362
445
  "data-phase": phase,
363
446
  children: label
@@ -394,33 +477,52 @@ function CountdownRunning({ seconds, className, style, children }) {
394
477
  ...style,
395
478
  ["--rrp-countdown"]: `${seconds}s`
396
479
  }), [style, seconds]);
397
- if (children) return /* @__PURE__ */ jsx("span", {
480
+ const srAnnouncement = /* @__PURE__ */ jsx("span", {
481
+ role: "status",
482
+ style: {
483
+ position: "absolute",
484
+ width: 1,
485
+ height: 1,
486
+ padding: 0,
487
+ margin: -1,
488
+ overflow: "hidden",
489
+ clip: "rect(0, 0, 0, 0)",
490
+ whiteSpace: "nowrap",
491
+ border: 0
492
+ },
493
+ children: `${seconds}-second countdown started`
494
+ });
495
+ if (children) return /* @__PURE__ */ jsxs("span", {
398
496
  className: cls,
399
497
  style: mergedStyle,
400
- "aria-hidden": "true",
401
- children: children(remaining)
498
+ children: [srAnnouncement, /* @__PURE__ */ jsx("span", {
499
+ "aria-hidden": "true",
500
+ children: children(remaining)
501
+ })]
402
502
  });
403
503
  return /* @__PURE__ */ jsxs("span", {
404
504
  className: cls,
405
505
  style: mergedStyle,
406
- "aria-hidden": "true",
407
- children: [/* @__PURE__ */ jsxs("svg", {
408
- className: "rrp-countdown__svg",
409
- viewBox: "0 0 36 36",
410
- children: [/* @__PURE__ */ jsx("circle", {
411
- className: "rrp-countdown__track",
412
- cx: "18",
413
- cy: "18",
414
- r: "16"
415
- }), /* @__PURE__ */ jsx("circle", {
416
- className: "rrp-countdown__bar",
417
- cx: "18",
418
- cy: "18",
419
- r: "16"
506
+ children: [srAnnouncement, /* @__PURE__ */ jsxs("span", {
507
+ "aria-hidden": "true",
508
+ children: [/* @__PURE__ */ jsxs("svg", {
509
+ className: "rrp-countdown__svg",
510
+ viewBox: "0 0 36 36",
511
+ children: [/* @__PURE__ */ jsx("circle", {
512
+ className: "rrp-countdown__track",
513
+ cx: "18",
514
+ cy: "18",
515
+ r: "16"
516
+ }), /* @__PURE__ */ jsx("circle", {
517
+ className: "rrp-countdown__bar",
518
+ cx: "18",
519
+ cy: "18",
520
+ r: "16"
521
+ })]
522
+ }), /* @__PURE__ */ jsx("span", {
523
+ className: "rrp-countdown__label",
524
+ children: remaining
420
525
  })]
421
- }), /* @__PURE__ */ jsx("span", {
422
- className: "rrp-countdown__label",
423
- children: remaining
424
526
  })]
425
527
  });
426
528
  }
@@ -428,25 +530,26 @@ function CountdownRunning({ seconds, className, style, children }) {
428
530
  //#region src/components/RafflePick/RafflePickSlots.tsx
429
531
  const pickRandom = (pool) => pool[Math.floor(Math.random() * pool.length)] ?? "";
430
532
  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)}}
533
+ .rrp-slot{display:inline-block;position:relative;overflow:hidden;vertical-align:baseline}
534
+ .rrp-slot__col{position:absolute;top:0;left:0;right:0;height:300%;transform:translate3d(0,-33.3333%,0);animation:rrp-slot-reel var(--rrp-tick,80ms) linear infinite;will-change:transform}
535
+ .rrp-slot__cell{height:33.3333%;display:grid;place-items:center;padding:0;margin:0;box-sizing:border-box;text-align:center}
536
+ .rrp-slot[data-stopped] .rrp-slot__col{animation:none;transform:translate3d(0,-33.3333%,0)}
537
+ @keyframes rrp-slot-reel{from{transform:translate3d(0,-33.3333%,0)}to{transform:translate3d(0,-66.6666%,0)}}
436
538
  `;
437
- let slotStylesInjected = false;
539
+ const SLOT_STYLES_VERSION = "4";
438
540
  const injectSlotStyles = () => {
439
- if (slotStylesInjected) return;
440
541
  if (typeof document === "undefined") return;
441
- if (document.querySelector("style[data-rrp-slot-base]")) {
442
- slotStylesInjected = true;
542
+ const existing = document.querySelector("style[data-rrp-slot-base]");
543
+ if (existing) {
544
+ if (existing.getAttribute("data-rrp-slot-base") === SLOT_STYLES_VERSION) return;
545
+ existing.textContent = SLOT_BASE_CSS;
546
+ existing.setAttribute("data-rrp-slot-base", SLOT_STYLES_VERSION);
443
547
  return;
444
548
  }
445
549
  const tag = document.createElement("style");
446
- tag.setAttribute("data-rrp-slot-base", "");
550
+ tag.setAttribute("data-rrp-slot-base", SLOT_STYLES_VERSION);
447
551
  tag.textContent = SLOT_BASE_CSS;
448
552
  document.head.appendChild(tag);
449
- slotStylesInjected = true;
450
553
  };
451
554
  const makeRefs = () => ({
452
555
  root: null,
@@ -458,7 +561,7 @@ const makeRefs = () => ({
458
561
  stopped: false
459
562
  });
460
563
  function RafflePickSlots({ length = 3, chars = "0123456789", spinInterval = 80, staggerMs = 220, className, slotClassName, style, slotStyle, onResult }) {
461
- injectSlotStyles();
564
+ useEffect(injectSlotStyles, []);
462
565
  const { phase, initialValue, finalValue } = useRaffleContext("RafflePick.Slots");
463
566
  const initialChars = useMemo(() => {
464
567
  const seed = typeof initialValue === "string" ? initialValue : "";
package/dist/styles.css CHANGED
@@ -4,29 +4,40 @@
4
4
 
5
5
  .rrp-slot {
6
6
  display: inline-block;
7
+ position: relative;
7
8
  overflow: hidden;
8
- height: 1em;
9
- line-height: 1em;
10
9
  vertical-align: baseline;
11
10
  }
12
11
  .rrp-slot__col {
13
- display: flex;
14
- flex-direction: column;
15
- transform: translateY(-1em);
12
+ position: absolute;
13
+ top: 0;
14
+ left: 0;
15
+ right: 0;
16
+ height: 300%;
17
+ transform: translate3d(0, -33.3333%, 0);
16
18
  animation: rrp-slot-reel var(--rrp-tick, 80ms) linear infinite;
19
+ will-change: transform;
17
20
  }
18
21
  .rrp-slot__cell {
19
- display: block;
20
- height: 1em;
21
- line-height: 1em;
22
+ height: 33.3333%;
23
+ display: grid;
24
+ place-items: center;
25
+ padding: 0;
26
+ margin: 0;
27
+ box-sizing: border-box;
28
+ text-align: center;
22
29
  }
23
30
  .rrp-slot[data-stopped] .rrp-slot__col {
24
31
  animation: none;
25
- transform: translateY(-1em);
32
+ transform: translate3d(0, -33.3333%, 0);
26
33
  }
27
34
  @keyframes rrp-slot-reel {
28
- from { transform: translateY(-1em); }
29
- to { transform: translateY(-2em); }
35
+ from {
36
+ transform: translate3d(0, -33.3333%, 0);
37
+ }
38
+ to {
39
+ transform: translate3d(0, -66.6666%, 0);
40
+ }
30
41
  }
31
42
 
32
43
  /* Value animations: opt-in via <RafflePick.Value animation="..." /> */
@@ -46,18 +57,48 @@
46
57
  animation: rrp-value-reel var(--rrp-tick, 100ms) linear;
47
58
  }
48
59
  @keyframes rrp-value-roll {
49
- from { transform: translateY(-0.4em); opacity: 0.4; }
50
- to { transform: translateY(0); opacity: 1; }
60
+ from {
61
+ transform: translateY(-0.4em);
62
+ opacity: 0.4;
63
+ }
64
+ to {
65
+ transform: translateY(0);
66
+ opacity: 1;
67
+ }
51
68
  }
52
69
  @keyframes rrp-value-fade {
53
- from { opacity: 0.2; }
54
- to { opacity: 1; }
70
+ from {
71
+ opacity: 0.2;
72
+ }
73
+ to {
74
+ opacity: 1;
75
+ }
55
76
  }
56
77
  @keyframes rrp-value-blur {
57
- from { filter: blur(4px); }
58
- to { filter: blur(0); }
78
+ from {
79
+ filter: blur(4px);
80
+ }
81
+ to {
82
+ filter: blur(0);
83
+ }
59
84
  }
60
85
  @keyframes rrp-value-reel {
61
- from { transform: translateY(-1em); opacity: 0; }
62
- to { transform: translateY(0); opacity: 1; }
86
+ from {
87
+ transform: translateY(-1em);
88
+ opacity: 0;
89
+ }
90
+ to {
91
+ transform: translateY(0);
92
+ opacity: 1;
93
+ }
94
+ }
95
+
96
+ /* Respect vestibular disorders — kill all motion, keep instant value swaps. */
97
+ @media (prefers-reduced-motion: reduce) {
98
+ .rrp-slot__col {
99
+ animation: none;
100
+ }
101
+ .rrp-value[data-animation] {
102
+ animation: none;
103
+ }
63
104
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-raffle-picker",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Headless React raffle picker for giveaways, winner draws, countdowns, and slot-machine UIs.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -111,5 +111,8 @@
111
111
  "typescript-eslint": "^8.58.2",
112
112
  "vite": "^8.0.8",
113
113
  "vitest": "^4.1.4"
114
+ },
115
+ "volta": {
116
+ "node": "22.12.0"
114
117
  }
115
118
  }