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 +49 -4
- package/dist/index.cjs +149 -43
- package/dist/index.d.cts +42 -2
- package/dist/index.d.mts +42 -2
- package/dist/index.mjs +150 -44
- package/dist/styles.css +78 -14
- package/package.json +1 -1
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://
|
|
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://
|
|
22
|
-
<img src="https://img.shields.io/
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
402
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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:
|
|
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 = "
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
401
|
-
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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:
|
|
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 = "
|
|
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:
|
|
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 {
|
|
36
|
-
|
|
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)
|
|
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)
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
61
|
-
|
|
85
|
+
0% {
|
|
86
|
+
opacity: 0.25;
|
|
87
|
+
}
|
|
88
|
+
100% {
|
|
89
|
+
opacity: 1;
|
|
90
|
+
}
|
|
62
91
|
}
|
|
63
92
|
@keyframes rrp-value-blur {
|
|
64
|
-
from {
|
|
65
|
-
|
|
93
|
+
from {
|
|
94
|
+
filter: blur(4px);
|
|
95
|
+
}
|
|
96
|
+
to {
|
|
97
|
+
filter: blur(0);
|
|
98
|
+
}
|
|
66
99
|
}
|
|
67
100
|
@keyframes rrp-value-reel {
|
|
68
|
-
from {
|
|
69
|
-
|
|
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
|
}
|