react-raffle-picker 0.2.2 → 0.3.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 CHANGED
@@ -15,11 +15,14 @@
15
15
  <a href="https://kirilinsky.github.io/react-raffle-picker/">
16
16
  <img src="https://img.shields.io/badge/storybook-live-ff4785?logo=storybook&logoColor=white" alt="Storybook" />
17
17
  </a>
18
- <a href="https://codecov.io/gh/kirilinsky/react-raffle-picker">
19
- <img src="https://codecov.io/gh/kirilinsky/react-raffle-picker/branch/main/graph/badge.svg" alt="Codecov coverage" />
18
+ <a href="https://app.codecov.io/gh/kirilinsky/react-raffle-picker">
19
+ <img src="https://img.shields.io/codecov/c/github/kirilinsky/react-raffle-picker?label=coverage" alt="Codecov coverage" />
20
20
  </a>
21
- <a href="https://bundlephobia.com/package/react-raffle-picker">
22
- <img src="https://img.shields.io/bundlephobia/minzip/react-raffle-picker?label=gzip" alt="Gzip bundle size" />
21
+ <a href="https://www.npmjs.com/package/react-raffle-picker">
22
+ <img src="https://img.shields.io/npm/v/react-raffle-picker" alt="npm version" />
23
+ </a>
24
+ <a href="https://www.npmjs.com/package/react-raffle-picker">
25
+ <img src="https://img.shields.io/npm/dm/react-raffle-picker" alt="npm downloads" />
23
26
  </a>
24
27
  </p>
25
28
  </div>
@@ -104,14 +107,18 @@ Provides context. Renders an optional wrapper element (`as` prop, default `'div'
104
107
  | `random` | `boolean` | `true` | Random pick vs sequential. |
105
108
  | `inertia` | `boolean` | `false` | Soft start / soft stop ramp. |
106
109
  | `autoStart` | `boolean` | `true` | Begin cycling on mount. |
110
+ | `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
111
  | `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
112
  | `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
113
  | `onSelect` | `(value) => void` | — | Fires once per round on freeze. |
114
+ | `onExhausted` | `() => void` | — | Fires when `start()` is called but `noRepeat` has already drawn every candidate. |
110
115
  | `as` | `ElementType` | `'div'` | Wrapper tag. |
111
116
  | `className` | `string` | — | Wrapper class. |
112
117
  | `style` | `CSSProperties` | — | Wrapper style. |
113
118
  | `children` | `ReactNode` | — | Sub-components. |
114
119
 
120
+ **`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.
121
+
115
122
  ### `<RafflePick.Value>`
116
123
 
117
124
  Renders the cycling value. Updates `textContent` imperatively each tick (no React re-render).
@@ -135,6 +142,7 @@ Toggles start / freeze based on phase. Disabled during settling.
135
142
  | `stopLabel` | `ReactNode` | Shown in `running` / `starting` (click stops). |
136
143
  | `waitLabel` | `ReactNode` | Shown in `settling` (button disabled). |
137
144
  | `children` | `ReactNode` | Fallback label when state-specific label absent. |
145
+ | `disabled` | `boolean` | External disable, on top of the auto-disable during `settling`. |
138
146
  | `className` | `string` | — |
139
147
  | `style` | `CSSProperties` | — |
140
148
 
@@ -202,6 +210,31 @@ Independent multi-reel slot machine. Each reel ticks on its own and stops with a
202
210
  </RafflePick>
203
211
  ```
204
212
 
213
+ ### Multi-round draw without repeat winners
214
+
215
+ `noRepeat` defaults to `true` — each subsequent round in the same mounted
216
+ `<RafflePick>` automatically excludes everyone already drawn. The Button
217
+ disables itself once the pool is empty.
218
+
219
+ ```tsx
220
+ function Giveaway() {
221
+ const [winners, setWinners] = useState<string[]>([])
222
+ return (
223
+ <RafflePick
224
+ items={['Alice', 'Bob', 'Carol', 'Dave']}
225
+ autoStart={false}
226
+ onSelect={(winner) => setWinners((w) => [...w, String(winner)])}
227
+ onExhausted={() => console.log('everyone already won')}
228
+ >
229
+ <RafflePick.Value />
230
+ <RafflePick.Button startLabel="Draw next" stopLabel="Stop" />
231
+ </RafflePick>
232
+ )
233
+ // Click "Draw next" repeatedly — Alice, Bob, Carol, Dave each win once,
234
+ // then the button disables itself. Pass noRepeat={false} to allow repeats.
235
+ }
236
+ ```
237
+
205
238
  ### Slot machine with custom result handler
206
239
 
207
240
  ```tsx
@@ -224,6 +257,18 @@ Independent multi-reel slot machine. Each reel ticks on its own and stops with a
224
257
  - Headless `useRafflePick()` hook for users who want zero rendering from the lib.
225
258
  - Render-prop variant of `<Value>` for fully custom DOM.
226
259
 
260
+ ## Accessibility
261
+
262
+ - `<RafflePick.Value>` cycles are visual-only (`aria-hidden`) — high-frequency tick
263
+ updates are not announced. The frozen result *is* announced once per round via a
264
+ hidden `aria-live="polite"` region.
265
+ - `<RafflePick.Countdown>`'s ring/label are `aria-hidden`; a one-time sr-only
266
+ announcement fires when the countdown starts. The result itself is still
267
+ announced by `<RafflePick.Value>` on freeze.
268
+ - `styles.css` respects `prefers-reduced-motion: reduce` — value animations and the
269
+ slot reel disable their `animation` under that media query. If you ship fully
270
+ custom CSS instead of the bundled stylesheet, add the same guard yourself.
271
+
227
272
  ## Performance notes
228
273
 
229
274
  - 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
  }
@@ -432,10 +534,14 @@ const SLOT_BASE_CSS = `
432
534
  .rrp-slot{display:inline-block;position:relative;overflow:hidden;vertical-align:baseline}
433
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}
434
536
  .rrp-slot__cell{height:33.3333%;display:grid;place-items:center;padding:0;margin:0;box-sizing:border-box;text-align:center}
435
- .rrp-slot[data-stopped] .rrp-slot__col{animation:none;transform:translate3d(0,-33.3333%,0)}
537
+ .rrp-slot[data-stopped] .rrp-slot__col{animation:rrp-slot-settle 320ms cubic-bezier(0.2,1.4,0.4,1) forwards}
436
538
  @keyframes rrp-slot-reel{from{transform:translate3d(0,-33.3333%,0)}to{transform:translate3d(0,-66.6666%,0)}}
539
+ @keyframes rrp-slot-settle{0%{transform:translate3d(0,-38%,0)}60%{transform:translate3d(0,-31%,0)}100%{transform:translate3d(0,-33.3333%,0)}}
540
+ @media (prefers-reduced-motion: reduce) {
541
+ .rrp-slot__col { animation: none !important; }
542
+ }
437
543
  `;
438
- const SLOT_STYLES_VERSION = "4";
544
+ const SLOT_STYLES_VERSION = "5";
439
545
  const injectSlotStyles = () => {
440
546
  if (typeof document === "undefined") return;
441
547
  const existing = document.querySelector("style[data-rrp-slot-base]");
@@ -460,7 +566,7 @@ const makeRefs = () => ({
460
566
  stopped: false
461
567
  });
462
568
  function RafflePickSlots({ length = 3, chars = "0123456789", spinInterval = 80, staggerMs = 220, className, slotClassName, style, slotStyle, onResult }) {
463
- injectSlotStyles();
569
+ (0, react.useEffect)(injectSlotStyles, []);
464
570
  const { phase, initialValue, finalValue } = useRaffleContext("RafflePick.Slots");
465
571
  const initialChars = (0, react.useMemo)(() => {
466
572
  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
  }
@@ -431,10 +533,14 @@ const SLOT_BASE_CSS = `
431
533
  .rrp-slot{display:inline-block;position:relative;overflow:hidden;vertical-align:baseline}
432
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}
433
535
  .rrp-slot__cell{height:33.3333%;display:grid;place-items:center;padding:0;margin:0;box-sizing:border-box;text-align:center}
434
- .rrp-slot[data-stopped] .rrp-slot__col{animation:none;transform:translate3d(0,-33.3333%,0)}
536
+ .rrp-slot[data-stopped] .rrp-slot__col{animation:rrp-slot-settle 320ms cubic-bezier(0.2,1.4,0.4,1) forwards}
435
537
  @keyframes rrp-slot-reel{from{transform:translate3d(0,-33.3333%,0)}to{transform:translate3d(0,-66.6666%,0)}}
538
+ @keyframes rrp-slot-settle{0%{transform:translate3d(0,-38%,0)}60%{transform:translate3d(0,-31%,0)}100%{transform:translate3d(0,-33.3333%,0)}}
539
+ @media (prefers-reduced-motion: reduce) {
540
+ .rrp-slot__col { animation: none !important; }
541
+ }
436
542
  `;
437
- const SLOT_STYLES_VERSION = "4";
543
+ const SLOT_STYLES_VERSION = "5";
438
544
  const injectSlotStyles = () => {
439
545
  if (typeof document === "undefined") return;
440
546
  const existing = document.querySelector("style[data-rrp-slot-base]");
@@ -459,7 +565,7 @@ const makeRefs = () => ({
459
565
  stopped: false
460
566
  });
461
567
  function RafflePickSlots({ length = 3, chars = "0123456789", spinInterval = 80, staggerMs = 220, className, slotClassName, style, slotStyle, onResult }) {
462
- injectSlotStyles();
568
+ useEffect(injectSlotStyles, []);
463
569
  const { phase, initialValue, finalValue } = useRaffleContext("RafflePick.Slots");
464
570
  const initialChars = useMemo(() => {
465
571
  const seed = typeof initialValue === "string" ? initialValue : "";
package/dist/styles.css CHANGED
@@ -27,13 +27,28 @@
27
27
  box-sizing: border-box;
28
28
  text-align: center;
29
29
  }
30
+ /* Stop = quick decelerate-and-settle bounce, not an instant snap. */
30
31
  .rrp-slot[data-stopped] .rrp-slot__col {
31
- animation: none;
32
- transform: translate3d(0, -33.3333%, 0);
32
+ animation: rrp-slot-settle 320ms cubic-bezier(0.2, 1.4, 0.4, 1) forwards;
33
33
  }
34
34
  @keyframes rrp-slot-reel {
35
- from { transform: translate3d(0, -33.3333%, 0); }
36
- to { transform: translate3d(0, -66.6666%, 0); }
35
+ from {
36
+ transform: translate3d(0, -33.3333%, 0);
37
+ }
38
+ to {
39
+ transform: translate3d(0, -66.6666%, 0);
40
+ }
41
+ }
42
+ @keyframes rrp-slot-settle {
43
+ 0% {
44
+ transform: translate3d(0, -38%, 0);
45
+ }
46
+ 60% {
47
+ transform: translate3d(0, -31%, 0);
48
+ }
49
+ 100% {
50
+ transform: translate3d(0, -33.3333%, 0);
51
+ }
37
52
  }
38
53
 
39
54
  /* Value animations: opt-in via <RafflePick.Value animation="..." /> */
@@ -41,10 +56,10 @@
41
56
  display: inline-block;
42
57
  }
43
58
  .rrp-value[data-animation='roll'][data-phase='running'] {
44
- animation: rrp-value-roll var(--rrp-tick, 100ms) linear;
59
+ animation: rrp-value-roll var(--rrp-tick, 100ms) cubic-bezier(0.22, 1, 0.36, 1);
45
60
  }
46
61
  .rrp-value[data-animation='fade'][data-phase='running'] {
47
- animation: rrp-value-fade var(--rrp-tick, 100ms) linear;
62
+ animation: rrp-value-fade var(--rrp-tick, 100ms) cubic-bezier(0.16, 1, 0.3, 1);
48
63
  }
49
64
  .rrp-value[data-animation='blur'][data-phase='running'] {
50
65
  animation: rrp-value-blur var(--rrp-tick, 100ms) linear;
@@ -52,19 +67,68 @@
52
67
  .rrp-value[data-animation='reel'][data-phase='running'] {
53
68
  animation: rrp-value-reel var(--rrp-tick, 100ms) linear;
54
69
  }
70
+ /* Freeze = one settle pop shared by every animation type — the "winner" beat. */
71
+ .rrp-value[data-animation][data-phase='frozen'] {
72
+ animation: rrp-value-settle 420ms cubic-bezier(0.2, 1.4, 0.4, 1);
73
+ }
55
74
  @keyframes rrp-value-roll {
56
- from { transform: translateY(-0.4em); opacity: 0.4; }
57
- to { transform: translateY(0); opacity: 1; }
75
+ 0% {
76
+ transform: translateY(-0.4em);
77
+ opacity: 0.45;
78
+ }
79
+ 100% {
80
+ transform: translateY(0);
81
+ opacity: 1;
82
+ }
58
83
  }
59
84
  @keyframes rrp-value-fade {
60
- from { opacity: 0.2; }
61
- to { opacity: 1; }
85
+ 0% {
86
+ opacity: 0.25;
87
+ }
88
+ 100% {
89
+ opacity: 1;
90
+ }
62
91
  }
63
92
  @keyframes rrp-value-blur {
64
- from { filter: blur(4px); }
65
- to { filter: blur(0); }
93
+ from {
94
+ filter: blur(4px);
95
+ }
96
+ to {
97
+ filter: blur(0);
98
+ }
66
99
  }
67
100
  @keyframes rrp-value-reel {
68
- from { transform: translateY(-1em); opacity: 0; }
69
- to { transform: translateY(0); opacity: 1; }
101
+ from {
102
+ transform: translateY(-1em);
103
+ opacity: 0;
104
+ }
105
+ to {
106
+ transform: translateY(0);
107
+ opacity: 1;
108
+ }
109
+ }
110
+ @keyframes rrp-value-settle {
111
+ 0% {
112
+ transform: scale(0.84);
113
+ opacity: 0.7;
114
+ }
115
+ 55% {
116
+ transform: scale(1.07);
117
+ opacity: 1;
118
+ }
119
+ 100% {
120
+ transform: scale(1);
121
+ }
122
+ }
123
+
124
+ /* Respect vestibular disorders — kill all motion, keep instant value swaps.
125
+ !important: these must always win, regardless of the specificity of
126
+ whatever running/settle rule above (or added later) also matches. */
127
+ @media (prefers-reduced-motion: reduce) {
128
+ .rrp-slot__col {
129
+ animation: none !important;
130
+ }
131
+ .rrp-value[data-animation] {
132
+ animation: none !important;
133
+ }
70
134
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-raffle-picker",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "description": "Headless React raffle picker for giveaways, winner draws, countdowns, and slot-machine UIs.",
5
5
  "type": "module",
6
6
  "publishConfig": {