@enigmax/primitives 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-W2DRHYC2.js → chunk-WCB7V7XO.js} +170 -3
- package/dist/index.d.ts +80 -3
- package/dist/index.js +1 -1
- package/dist/react/index.d.ts +38 -4
- package/dist/react/index.js +56 -4
- package/package.json +1 -1
- package/recipes/marquee.css.tsx +9 -5
- package/recipes/marquee.tailwind.tsx +9 -5
- package/registry.json +55 -0
- package/src/core/button.ts +238 -0
- package/src/core/marquee.ts +28 -4
- package/src/index.ts +2 -1
- package/src/react/index.ts +3 -1
- package/src/react/use-button.ts +89 -0
- package/src/react/use-marquee.ts +2 -2
|
@@ -11,7 +11,7 @@ var DEFAULTS = {
|
|
|
11
11
|
reverse: false,
|
|
12
12
|
vertical: false,
|
|
13
13
|
draggable: true,
|
|
14
|
-
|
|
14
|
+
hover: "off",
|
|
15
15
|
decay: DEFAULT_DECAY,
|
|
16
16
|
manageStyles: true,
|
|
17
17
|
copies: "clone"
|
|
@@ -37,10 +37,17 @@ function createMarquee(lane, track, options = {}) {
|
|
|
37
37
|
let lastTouchAt = 0;
|
|
38
38
|
const motionQuery = typeof window.matchMedia === "function" ? window.matchMedia("(prefers-reduced-motion: reduce)") : null;
|
|
39
39
|
let reducedMotion = motionQuery?.matches ?? false;
|
|
40
|
+
function hoverTarget(cruise) {
|
|
41
|
+
const hover = opts.hover ?? "off";
|
|
42
|
+
if (hover === "off") return cruise;
|
|
43
|
+
if (hover === "pause") return 0;
|
|
44
|
+
if (typeof hover === "number") return cruise * hover;
|
|
45
|
+
return (cruise < 0 ? -1 : 1) * Math.abs(hover.speed);
|
|
46
|
+
}
|
|
40
47
|
function target() {
|
|
41
48
|
if (reducedMotion || paused) return 0;
|
|
42
49
|
const cruise = opts.reverse ? -opts.speed : opts.speed;
|
|
43
|
-
return hovering ? cruise
|
|
50
|
+
return hovering ? hoverTarget(cruise) : cruise;
|
|
44
51
|
}
|
|
45
52
|
function axisSize(element) {
|
|
46
53
|
return opts.vertical ? element.offsetHeight : element.offsetWidth;
|
|
@@ -470,4 +477,164 @@ function createInput(input, options = {}) {
|
|
|
470
477
|
};
|
|
471
478
|
}
|
|
472
479
|
|
|
473
|
-
|
|
480
|
+
// src/core/button.ts
|
|
481
|
+
var TICK_MS = 100;
|
|
482
|
+
function store(cooldown) {
|
|
483
|
+
if (!cooldown.key || typeof window === "undefined") return null;
|
|
484
|
+
try {
|
|
485
|
+
const target = cooldown.storage === "local" ? window.localStorage : window.sessionStorage;
|
|
486
|
+
const probe = "__enigma_probe__";
|
|
487
|
+
target.setItem(probe, "1");
|
|
488
|
+
target.removeItem(probe);
|
|
489
|
+
return target;
|
|
490
|
+
} catch {
|
|
491
|
+
return null;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
function normalize(cooldown) {
|
|
495
|
+
if (cooldown == null) return null;
|
|
496
|
+
return typeof cooldown === "number" ? { ms: cooldown } : cooldown;
|
|
497
|
+
}
|
|
498
|
+
function isTyping(target) {
|
|
499
|
+
const element = target;
|
|
500
|
+
if (!element) return false;
|
|
501
|
+
const tag = element.tagName;
|
|
502
|
+
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || element.isContentEditable === true;
|
|
503
|
+
}
|
|
504
|
+
function createButton(options = {}) {
|
|
505
|
+
let opts = { ...options };
|
|
506
|
+
let loading = Boolean(opts.loading);
|
|
507
|
+
let readyAt = 0;
|
|
508
|
+
let timer = null;
|
|
509
|
+
let destroyed = false;
|
|
510
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
511
|
+
const storageKey = () => {
|
|
512
|
+
const cooldown = normalize(opts.cooldown);
|
|
513
|
+
return cooldown?.key ? `enigma:cooldown:${cooldown.key}` : null;
|
|
514
|
+
};
|
|
515
|
+
function restore() {
|
|
516
|
+
const cooldown = normalize(opts.cooldown);
|
|
517
|
+
if (!cooldown) return;
|
|
518
|
+
const target = store(cooldown);
|
|
519
|
+
const key = storageKey();
|
|
520
|
+
if (!target || !key) return;
|
|
521
|
+
const saved = Number(target.getItem(key));
|
|
522
|
+
if (Number.isFinite(saved) && saved > Date.now()) readyAt = saved;
|
|
523
|
+
}
|
|
524
|
+
function remaining() {
|
|
525
|
+
return Math.max(0, readyAt - Date.now());
|
|
526
|
+
}
|
|
527
|
+
function snapshot() {
|
|
528
|
+
const disabled = Boolean(opts.disabled);
|
|
529
|
+
const cooldown = remaining();
|
|
530
|
+
return {
|
|
531
|
+
element: opts.href ? "a" : "button",
|
|
532
|
+
available: !disabled && !loading && cooldown === 0,
|
|
533
|
+
loading,
|
|
534
|
+
disabled,
|
|
535
|
+
cooldown,
|
|
536
|
+
shortcut: opts.shortcut ?? null
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
function emit() {
|
|
540
|
+
const state = snapshot();
|
|
541
|
+
opts.onChange?.(state);
|
|
542
|
+
for (const listener of listeners) listener(state);
|
|
543
|
+
}
|
|
544
|
+
function stopTicking() {
|
|
545
|
+
if (timer === null) return;
|
|
546
|
+
clearInterval(timer);
|
|
547
|
+
timer = null;
|
|
548
|
+
}
|
|
549
|
+
function startTicking() {
|
|
550
|
+
if (timer !== null || remaining() === 0) return;
|
|
551
|
+
timer = setInterval(() => {
|
|
552
|
+
if (remaining() > 0) {
|
|
553
|
+
emit();
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
stopTicking();
|
|
557
|
+
emit();
|
|
558
|
+
}, TICK_MS);
|
|
559
|
+
}
|
|
560
|
+
function beginCooldown() {
|
|
561
|
+
const cooldown = normalize(opts.cooldown);
|
|
562
|
+
if (!cooldown || cooldown.ms <= 0) return;
|
|
563
|
+
readyAt = Date.now() + cooldown.ms;
|
|
564
|
+
const target = store(cooldown);
|
|
565
|
+
const key = storageKey();
|
|
566
|
+
if (target && key) {
|
|
567
|
+
try {
|
|
568
|
+
target.setItem(key, String(readyAt));
|
|
569
|
+
} catch {
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
startTicking();
|
|
573
|
+
}
|
|
574
|
+
async function press(event) {
|
|
575
|
+
if (destroyed || !snapshot().available) return;
|
|
576
|
+
const result = opts.onPress?.(event);
|
|
577
|
+
if (result instanceof Promise) {
|
|
578
|
+
loading = true;
|
|
579
|
+
emit();
|
|
580
|
+
try {
|
|
581
|
+
await result;
|
|
582
|
+
} finally {
|
|
583
|
+
loading = false;
|
|
584
|
+
beginCooldown();
|
|
585
|
+
emit();
|
|
586
|
+
}
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
beginCooldown();
|
|
590
|
+
emit();
|
|
591
|
+
}
|
|
592
|
+
function onKeyDown(event) {
|
|
593
|
+
if (!opts.shortcut || isTyping(event.target)) return;
|
|
594
|
+
if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return;
|
|
595
|
+
if (event.key.toLowerCase() !== opts.shortcut.toLowerCase()) return;
|
|
596
|
+
if (!snapshot().available) return;
|
|
597
|
+
event.preventDefault();
|
|
598
|
+
void press(event);
|
|
599
|
+
}
|
|
600
|
+
if (typeof window !== "undefined") {
|
|
601
|
+
restore();
|
|
602
|
+
startTicking();
|
|
603
|
+
window.addEventListener("keydown", onKeyDown);
|
|
604
|
+
}
|
|
605
|
+
return {
|
|
606
|
+
get state() {
|
|
607
|
+
return snapshot();
|
|
608
|
+
},
|
|
609
|
+
press,
|
|
610
|
+
update(next) {
|
|
611
|
+
const hadCooldown = JSON.stringify(normalize(opts.cooldown));
|
|
612
|
+
opts = { ...opts, ...next };
|
|
613
|
+
if (next.loading !== void 0) loading = Boolean(next.loading);
|
|
614
|
+
if (JSON.stringify(normalize(opts.cooldown)) !== hadCooldown) restore();
|
|
615
|
+
emit();
|
|
616
|
+
},
|
|
617
|
+
reset() {
|
|
618
|
+
readyAt = 0;
|
|
619
|
+
stopTicking();
|
|
620
|
+
const cooldown = normalize(opts.cooldown);
|
|
621
|
+
const key = storageKey();
|
|
622
|
+
if (cooldown && key) store(cooldown)?.removeItem(key);
|
|
623
|
+
emit();
|
|
624
|
+
},
|
|
625
|
+
subscribe(listener) {
|
|
626
|
+
listeners.add(listener);
|
|
627
|
+
return () => {
|
|
628
|
+
listeners.delete(listener);
|
|
629
|
+
};
|
|
630
|
+
},
|
|
631
|
+
destroy() {
|
|
632
|
+
destroyed = true;
|
|
633
|
+
stopTicking();
|
|
634
|
+
listeners.clear();
|
|
635
|
+
if (typeof window !== "undefined") window.removeEventListener("keydown", onKeyDown);
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
export { createButton, createInput, createMarquee };
|
package/dist/index.d.ts
CHANGED
|
@@ -11,6 +11,21 @@ export { F as FuseConstructor, a as FuseLike, S as SearchInstance, b as SearchMa
|
|
|
11
11
|
* comments marked "non-negotiable" name the bug that the obvious implementation
|
|
12
12
|
* shipped with.
|
|
13
13
|
*/
|
|
14
|
+
/**
|
|
15
|
+
* What a MOUSE resting on the row does to its speed. One option covers every case, so
|
|
16
|
+
* there is no pile of booleans to reconcile:
|
|
17
|
+
*
|
|
18
|
+
* - `"off"` ignore hover entirely (the default)
|
|
19
|
+
* - `"pause"` stop while the pointer is on it
|
|
20
|
+
* - a number multiply the cruise speed: `0.15` crawls, `1` changes nothing, `2` doubles
|
|
21
|
+
* - `{ speed }` an ABSOLUTE speed in px/s, whatever the cruise is; direction still
|
|
22
|
+
* follows `reverse`, so you give a magnitude and not a sign
|
|
23
|
+
*
|
|
24
|
+
* Touch never triggers any of it - see the pointerType note on the hover handler.
|
|
25
|
+
*/
|
|
26
|
+
type MarqueeHover = number | "off" | "pause" | {
|
|
27
|
+
speed: number;
|
|
28
|
+
};
|
|
14
29
|
interface MarqueeOptions {
|
|
15
30
|
/**
|
|
16
31
|
* Pixels per second. NOT a duration: a duration ties the speed to the item
|
|
@@ -23,8 +38,8 @@ interface MarqueeOptions {
|
|
|
23
38
|
vertical?: boolean;
|
|
24
39
|
/** Allow grabbing and throwing the row. */
|
|
25
40
|
draggable?: boolean;
|
|
26
|
-
/**
|
|
27
|
-
|
|
41
|
+
/** What a mouse resting on the row does to its speed. See {@link MarqueeHover}. */
|
|
42
|
+
hover?: MarqueeHover;
|
|
28
43
|
/** Fraction of the remaining velocity gap left after one second. */
|
|
29
44
|
decay?: number;
|
|
30
45
|
/**
|
|
@@ -147,4 +162,66 @@ interface InputInstance {
|
|
|
147
162
|
*/
|
|
148
163
|
declare function createInput(input: HTMLInputElement, options?: InputOptions): InputInstance;
|
|
149
164
|
|
|
150
|
-
|
|
165
|
+
/**
|
|
166
|
+
* Button behaviour: what makes it unavailable, and everything that follows from that.
|
|
167
|
+
*
|
|
168
|
+
* Disabled, loading and a cooldown are three reasons for the same state, so they collapse
|
|
169
|
+
* into one `available` the renderer reads, instead of three flags every call site has to
|
|
170
|
+
* combine correctly. The element to render is reported rather than chosen, because a
|
|
171
|
+
* framework-agnostic package cannot import next/link.
|
|
172
|
+
*/
|
|
173
|
+
/** Which tag the consumer should render. An href makes it a link, and a link is not a button. */
|
|
174
|
+
type ButtonElement = "button" | "a";
|
|
175
|
+
interface ButtonCooldown {
|
|
176
|
+
/** How long the button stays unavailable after a press, in ms. */
|
|
177
|
+
ms: number;
|
|
178
|
+
/**
|
|
179
|
+
* Survive a reload under this key. Without it the cooldown is in memory only, and a
|
|
180
|
+
* refresh is a free retry - which is the whole thing a cooldown exists to prevent.
|
|
181
|
+
*/
|
|
182
|
+
key?: string;
|
|
183
|
+
storage?: "local" | "session";
|
|
184
|
+
}
|
|
185
|
+
interface ButtonOptions {
|
|
186
|
+
/** Turns into an `a`, and a link cannot be `disabled` - only `aria-disabled`. */
|
|
187
|
+
href?: string;
|
|
188
|
+
disabled?: boolean;
|
|
189
|
+
/** Unavailable and busy. Set it yourself, or let an async `onPress` manage it. */
|
|
190
|
+
loading?: boolean;
|
|
191
|
+
/** ms, or the full shape for a cooldown that outlives a reload. */
|
|
192
|
+
cooldown?: number | ButtonCooldown;
|
|
193
|
+
/**
|
|
194
|
+
* A single key that presses the button. Ignored while the visitor is typing, and
|
|
195
|
+
* while any modifier is held, so it never steals a real shortcut.
|
|
196
|
+
*/
|
|
197
|
+
shortcut?: string;
|
|
198
|
+
/** Async work flips `loading` for its duration and only then starts the cooldown. */
|
|
199
|
+
onPress?: (event?: Event) => void | Promise<void>;
|
|
200
|
+
/** Called whenever anything below changes. */
|
|
201
|
+
onChange?: (state: ButtonState) => void;
|
|
202
|
+
}
|
|
203
|
+
interface ButtonState {
|
|
204
|
+
/** The tag to render. */
|
|
205
|
+
element: ButtonElement;
|
|
206
|
+
/** Pressable: not disabled, not loading, not cooling down. */
|
|
207
|
+
available: boolean;
|
|
208
|
+
loading: boolean;
|
|
209
|
+
disabled: boolean;
|
|
210
|
+
/** ms left on the cooldown, 0 when there is none. */
|
|
211
|
+
cooldown: number;
|
|
212
|
+
/** The accessible name for the shortcut, when there is one. */
|
|
213
|
+
shortcut: string | null;
|
|
214
|
+
}
|
|
215
|
+
interface ButtonInstance {
|
|
216
|
+
readonly state: ButtonState;
|
|
217
|
+
/** Run the press as if it had been clicked. Ignored while unavailable. */
|
|
218
|
+
press(event?: Event): Promise<void>;
|
|
219
|
+
update(options: Partial<ButtonOptions>): void;
|
|
220
|
+
/** Clear a cooldown early, including its stored entry. */
|
|
221
|
+
reset(): void;
|
|
222
|
+
subscribe(listener: (state: ButtonState) => void): () => void;
|
|
223
|
+
destroy(): void;
|
|
224
|
+
}
|
|
225
|
+
declare function createButton(options?: ButtonOptions): ButtonInstance;
|
|
226
|
+
|
|
227
|
+
export { type ButtonCooldown, type ButtonElement, type ButtonInstance, type ButtonOptions, type ButtonState, type InputAction, type InputActionState, type InputIcon, type InputInstance, type InputOptions, type MarqueeHover, type MarqueeInstance, type MarqueeOptions, createButton, createInput, createMarquee };
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { createInput, createMarquee } from './chunk-
|
|
1
|
+
export { createButton, createInput, createMarquee } from './chunk-WCB7V7XO.js';
|
|
2
2
|
export { createSearch } from './chunk-UZFEEFMF.js';
|
package/dist/react/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { RefObject } from 'react';
|
|
2
|
-
import { MarqueeOptions, InputOptions } from '../index.js';
|
|
3
|
-
export { InputAction, InputIcon, MarqueeInstance } from '../index.js';
|
|
2
|
+
import { MarqueeOptions, InputOptions, ButtonState, ButtonOptions } from '../index.js';
|
|
3
|
+
export { InputAction, InputIcon, MarqueeHover, MarqueeInstance } from '../index.js';
|
|
4
4
|
import { b as SearchMatch, c as SearchOptions } from '../search-CsO3L1Lw.js';
|
|
5
5
|
export { F as FuseConstructor } from '../search-CsO3L1Lw.js';
|
|
6
6
|
|
|
@@ -28,7 +28,7 @@ interface UseMarqueeResult {
|
|
|
28
28
|
* to the item count, so the row accelerates every time content is added.
|
|
29
29
|
*
|
|
30
30
|
* ```tsx
|
|
31
|
-
* const { laneRef, trackRef, copies } = useMarquee({ speed: 80,
|
|
31
|
+
* const { laneRef, trackRef, copies } = useMarquee({ speed: 80, hover: 0.15 });
|
|
32
32
|
* return (
|
|
33
33
|
* <div ref={laneRef}>
|
|
34
34
|
* <div ref={trackRef} style={{ display: "flex" }}>
|
|
@@ -94,4 +94,38 @@ interface UseSearchResult<T> {
|
|
|
94
94
|
*/
|
|
95
95
|
declare function useSearch<T>(options?: SearchOptions<T>): UseSearchResult<T>;
|
|
96
96
|
|
|
97
|
-
|
|
97
|
+
interface UseButtonResult extends ButtonState {
|
|
98
|
+
/** Spread onto the element named by `element`. */
|
|
99
|
+
props: {
|
|
100
|
+
onClick: (event: {
|
|
101
|
+
preventDefault(): void;
|
|
102
|
+
}) => void;
|
|
103
|
+
"aria-disabled": boolean;
|
|
104
|
+
"aria-busy": boolean;
|
|
105
|
+
"data-loading"?: "";
|
|
106
|
+
"data-cooldown"?: "";
|
|
107
|
+
/** Only on a real button: an anchor has no `disabled`. */
|
|
108
|
+
disabled?: boolean;
|
|
109
|
+
href?: string;
|
|
110
|
+
title?: string;
|
|
111
|
+
};
|
|
112
|
+
press: () => void;
|
|
113
|
+
reset: () => void;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Button behaviour: disabled, loading, a cooldown and a keyboard shortcut collapsed into
|
|
117
|
+
* one `available`, plus the attributes that follow from it.
|
|
118
|
+
*
|
|
119
|
+
* ```tsx
|
|
120
|
+
* const { element: Tag, props, loading, cooldown } = useButton({
|
|
121
|
+
* cooldown: { ms: 30_000, key: "resend-code", storage: "local" },
|
|
122
|
+
* shortcut: "r",
|
|
123
|
+
* onPress: () => resendCode()
|
|
124
|
+
* });
|
|
125
|
+
*
|
|
126
|
+
* return <Tag {...props}>{loading ? "Sending" : cooldown ? `Wait ${Math.ceil(cooldown / 1000)}s` : "Resend"}</Tag>;
|
|
127
|
+
* ```
|
|
128
|
+
*/
|
|
129
|
+
declare function useButton(options?: ButtonOptions): UseButtonResult;
|
|
130
|
+
|
|
131
|
+
export { ButtonOptions, ButtonState, InputOptions, MarqueeOptions, SearchMatch, SearchOptions, type UseButtonResult, type UseInputResult, type UseMarqueeResult, type UseSearchResult, useButton, useInput, useMarquee, useSearch };
|
package/dist/react/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { createMarquee, createInput } from '../chunk-
|
|
1
|
+
import { createMarquee, createInput, createButton } from '../chunk-WCB7V7XO.js';
|
|
2
2
|
import { createSearch } from '../chunk-UZFEEFMF.js';
|
|
3
|
-
import { useRef, useState, useMemo, useEffect, useLayoutEffect } from 'react';
|
|
3
|
+
import { useRef, useState, useMemo, useEffect, useCallback, useLayoutEffect } from 'react';
|
|
4
4
|
|
|
5
5
|
var useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
6
6
|
function useMarquee(options = {}) {
|
|
@@ -39,7 +39,7 @@ function useMarquee(options = {}) {
|
|
|
39
39
|
}, []);
|
|
40
40
|
useIsomorphicLayoutEffect(() => {
|
|
41
41
|
instanceRef.current?.update(options);
|
|
42
|
-
}, [options.speed, options.reverse, options.vertical, options.draggable, options.
|
|
42
|
+
}, [options.speed, options.reverse, options.vertical, options.draggable, options.hover, options.decay]);
|
|
43
43
|
useIsomorphicLayoutEffect(() => {
|
|
44
44
|
instanceRef.current?.measure();
|
|
45
45
|
}, [copies]);
|
|
@@ -130,5 +130,57 @@ function useSearch(options = {}) {
|
|
|
130
130
|
}
|
|
131
131
|
};
|
|
132
132
|
}
|
|
133
|
+
function useButton(options = {}) {
|
|
134
|
+
const optionsRef = useRef(options);
|
|
135
|
+
optionsRef.current = options;
|
|
136
|
+
const instance = useMemo(() => createButton({
|
|
137
|
+
...optionsRef.current,
|
|
138
|
+
onPress: (event) => optionsRef.current.onPress?.(event),
|
|
139
|
+
onChange: (next) => optionsRef.current.onChange?.(next)
|
|
140
|
+
// Built once: recreating it would drop a running cooldown on every render.
|
|
141
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
142
|
+
}), []);
|
|
143
|
+
const [state, setState] = useState(() => instance.state);
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
const unsubscribe = instance.subscribe(setState);
|
|
146
|
+
setState(instance.state);
|
|
147
|
+
return () => {
|
|
148
|
+
unsubscribe();
|
|
149
|
+
instance.destroy();
|
|
150
|
+
};
|
|
151
|
+
}, [instance]);
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
instance.update({
|
|
154
|
+
href: options.href,
|
|
155
|
+
disabled: options.disabled,
|
|
156
|
+
loading: options.loading,
|
|
157
|
+
cooldown: options.cooldown,
|
|
158
|
+
shortcut: options.shortcut
|
|
159
|
+
});
|
|
160
|
+
}, [instance, options.href, options.disabled, options.loading, options.cooldown, options.shortcut]);
|
|
161
|
+
const press = useCallback(() => {
|
|
162
|
+
void instance.press();
|
|
163
|
+
}, [instance]);
|
|
164
|
+
return {
|
|
165
|
+
...state,
|
|
166
|
+
press,
|
|
167
|
+
reset: () => instance.reset(),
|
|
168
|
+
props: {
|
|
169
|
+
onClick: (event) => {
|
|
170
|
+
if (!state.available) {
|
|
171
|
+
event.preventDefault();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
void instance.press();
|
|
175
|
+
},
|
|
176
|
+
"aria-disabled": !state.available,
|
|
177
|
+
"aria-busy": state.loading,
|
|
178
|
+
...state.loading ? { "data-loading": "" } : {},
|
|
179
|
+
...state.cooldown > 0 ? { "data-cooldown": "" } : {},
|
|
180
|
+
...state.element === "button" ? { disabled: state.disabled } : { href: options.href },
|
|
181
|
+
...state.shortcut ? { title: `Shortcut: ${state.shortcut.toUpperCase()}` } : {}
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
}
|
|
133
185
|
|
|
134
|
-
export { useInput, useMarquee, useSearch };
|
|
186
|
+
export { useButton, useInput, useMarquee, useSearch };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enigmax/primitives",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Headless interaction primitives: the behaviour, the timing and the accessibility of components like a draggable infinite marquee, with no styles of their own. Framework-agnostic core plus thin adapters.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
package/recipes/marquee.css.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import "./marquee.css";
|
|
2
2
|
import type { ReactNode, Ref } from "react";
|
|
3
|
-
import { useMarquee } from "@enigmax/primitives/react";
|
|
3
|
+
import { useMarquee, type MarqueeHover } from "@enigmax/primitives/react";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* A styled marquee, yours to edit.
|
|
@@ -12,8 +12,12 @@ export interface MarqueeProps<T> {
|
|
|
12
12
|
items: T[];
|
|
13
13
|
/** Pixels per second. Never a duration - the row would speed up as items are added. */
|
|
14
14
|
speed?: number;
|
|
15
|
-
/**
|
|
16
|
-
|
|
15
|
+
/**
|
|
16
|
+
* What a mouse resting on the row does to its speed:
|
|
17
|
+
* "off" ignores hover, "pause" stops, a number multiplies the cruise speed
|
|
18
|
+
* (0.15 crawls, 2 doubles), { speed } sets an absolute px/s.
|
|
19
|
+
*/
|
|
20
|
+
hover?: MarqueeHover;
|
|
17
21
|
reverse?: boolean;
|
|
18
22
|
/** Fade both ends so items enter and leave instead of being cut. */
|
|
19
23
|
fade?: boolean;
|
|
@@ -24,13 +28,13 @@ export interface MarqueeProps<T> {
|
|
|
24
28
|
export function Marquee<T>({
|
|
25
29
|
items,
|
|
26
30
|
speed = 70,
|
|
27
|
-
|
|
31
|
+
hover = "off",
|
|
28
32
|
reverse = false,
|
|
29
33
|
fade = true,
|
|
30
34
|
className = "",
|
|
31
35
|
children
|
|
32
36
|
}: MarqueeProps<T>) {
|
|
33
|
-
const { laneRef, trackRef, copies, dragging } = useMarquee({ speed,
|
|
37
|
+
const { laneRef, trackRef, copies, dragging } = useMarquee({ speed, hover, reverse });
|
|
34
38
|
|
|
35
39
|
return (
|
|
36
40
|
<div
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ReactNode, Ref } from "react";
|
|
2
|
-
import { useMarquee } from "@enigmax/primitives/react";
|
|
2
|
+
import { useMarquee, type MarqueeHover } from "@enigmax/primitives/react";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* A styled marquee, yours to edit.
|
|
@@ -16,8 +16,12 @@ export interface MarqueeProps<T> {
|
|
|
16
16
|
items: T[];
|
|
17
17
|
/** Pixels per second. Never a duration - the row would speed up as items are added. */
|
|
18
18
|
speed?: number;
|
|
19
|
-
/**
|
|
20
|
-
|
|
19
|
+
/**
|
|
20
|
+
* What a mouse resting on the row does to its speed:
|
|
21
|
+
* "off" ignores hover, "pause" stops, a number multiplies the cruise speed
|
|
22
|
+
* (0.15 crawls, 2 doubles), { speed } sets an absolute px/s.
|
|
23
|
+
*/
|
|
24
|
+
hover?: MarqueeHover;
|
|
21
25
|
reverse?: boolean;
|
|
22
26
|
/** Fade both ends so items enter and leave instead of being cut. */
|
|
23
27
|
fade?: boolean;
|
|
@@ -28,13 +32,13 @@ export interface MarqueeProps<T> {
|
|
|
28
32
|
export function Marquee<T>({
|
|
29
33
|
items,
|
|
30
34
|
speed = 70,
|
|
31
|
-
|
|
35
|
+
hover = "off",
|
|
32
36
|
reverse = false,
|
|
33
37
|
fade = true,
|
|
34
38
|
className = "",
|
|
35
39
|
children
|
|
36
40
|
}: MarqueeProps<T>) {
|
|
37
|
-
const { laneRef, trackRef, copies, dragging } = useMarquee({ speed,
|
|
41
|
+
const { laneRef, trackRef, copies, dragging } = useMarquee({ speed, hover, reverse });
|
|
38
42
|
|
|
39
43
|
return (
|
|
40
44
|
<div
|
package/registry.json
CHANGED
|
@@ -245,6 +245,61 @@
|
|
|
245
245
|
"dependencies": {
|
|
246
246
|
"fuse.js": "^7.0.0"
|
|
247
247
|
}
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
"name": "button",
|
|
251
|
+
"title": "Button behaviour",
|
|
252
|
+
"description": "Disabled, loading, a keyboard shortcut and a cooldown that can outlive a reload, collapsed into one `available` the renderer reads. An href reports an anchor rather than a button, so the framework's own Link stays your choice.",
|
|
253
|
+
"targets": [
|
|
254
|
+
"vanilla",
|
|
255
|
+
"astro",
|
|
256
|
+
"react"
|
|
257
|
+
],
|
|
258
|
+
"entry": {
|
|
259
|
+
"vanilla": "@enigmax/primitives",
|
|
260
|
+
"astro": "@enigmax/primitives",
|
|
261
|
+
"react": "@enigmax/primitives/react"
|
|
262
|
+
},
|
|
263
|
+
"exports": {
|
|
264
|
+
"vanilla": [
|
|
265
|
+
"createButton"
|
|
266
|
+
],
|
|
267
|
+
"astro": [
|
|
268
|
+
"createButton"
|
|
269
|
+
],
|
|
270
|
+
"react": [
|
|
271
|
+
"useButton"
|
|
272
|
+
]
|
|
273
|
+
},
|
|
274
|
+
"files": [
|
|
275
|
+
{
|
|
276
|
+
"path": "src/core/button.ts",
|
|
277
|
+
"dest": "button.ts",
|
|
278
|
+
"targets": [
|
|
279
|
+
"vanilla",
|
|
280
|
+
"astro",
|
|
281
|
+
"react"
|
|
282
|
+
]
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
"path": "src/react/use-button.ts",
|
|
286
|
+
"dest": "use-button.ts",
|
|
287
|
+
"targets": [
|
|
288
|
+
"react"
|
|
289
|
+
],
|
|
290
|
+
"rewrite": {
|
|
291
|
+
"@/core/button": "./button"
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
],
|
|
295
|
+
"styles": false,
|
|
296
|
+
"themeHooks": [
|
|
297
|
+
"[data-loading]",
|
|
298
|
+
"[data-cooldown]",
|
|
299
|
+
"[aria-disabled=true]",
|
|
300
|
+
"[aria-busy=true]"
|
|
301
|
+
],
|
|
302
|
+
"docs": "docs/notes/primitives.md#button"
|
|
248
303
|
}
|
|
249
304
|
]
|
|
250
305
|
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Button behaviour: what makes it unavailable, and everything that follows from that.
|
|
3
|
+
*
|
|
4
|
+
* Disabled, loading and a cooldown are three reasons for the same state, so they collapse
|
|
5
|
+
* into one `available` the renderer reads, instead of three flags every call site has to
|
|
6
|
+
* combine correctly. The element to render is reported rather than chosen, because a
|
|
7
|
+
* framework-agnostic package cannot import next/link.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Which tag the consumer should render. An href makes it a link, and a link is not a button. */
|
|
11
|
+
export type ButtonElement = "button" | "a";
|
|
12
|
+
|
|
13
|
+
export interface ButtonCooldown {
|
|
14
|
+
/** How long the button stays unavailable after a press, in ms. */
|
|
15
|
+
ms: number;
|
|
16
|
+
/**
|
|
17
|
+
* Survive a reload under this key. Without it the cooldown is in memory only, and a
|
|
18
|
+
* refresh is a free retry - which is the whole thing a cooldown exists to prevent.
|
|
19
|
+
*/
|
|
20
|
+
key?: string;
|
|
21
|
+
storage?: "local" | "session";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ButtonOptions {
|
|
25
|
+
/** Turns into an `a`, and a link cannot be `disabled` - only `aria-disabled`. */
|
|
26
|
+
href?: string;
|
|
27
|
+
disabled?: boolean;
|
|
28
|
+
/** Unavailable and busy. Set it yourself, or let an async `onPress` manage it. */
|
|
29
|
+
loading?: boolean;
|
|
30
|
+
/** ms, or the full shape for a cooldown that outlives a reload. */
|
|
31
|
+
cooldown?: number | ButtonCooldown;
|
|
32
|
+
/**
|
|
33
|
+
* A single key that presses the button. Ignored while the visitor is typing, and
|
|
34
|
+
* while any modifier is held, so it never steals a real shortcut.
|
|
35
|
+
*/
|
|
36
|
+
shortcut?: string;
|
|
37
|
+
/** Async work flips `loading` for its duration and only then starts the cooldown. */
|
|
38
|
+
onPress?: (event?: Event) => void | Promise<void>;
|
|
39
|
+
/** Called whenever anything below changes. */
|
|
40
|
+
onChange?: (state: ButtonState) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ButtonState {
|
|
44
|
+
/** The tag to render. */
|
|
45
|
+
element: ButtonElement;
|
|
46
|
+
/** Pressable: not disabled, not loading, not cooling down. */
|
|
47
|
+
available: boolean;
|
|
48
|
+
loading: boolean;
|
|
49
|
+
disabled: boolean;
|
|
50
|
+
/** ms left on the cooldown, 0 when there is none. */
|
|
51
|
+
cooldown: number;
|
|
52
|
+
/** The accessible name for the shortcut, when there is one. */
|
|
53
|
+
shortcut: string | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ButtonInstance {
|
|
57
|
+
readonly state: ButtonState;
|
|
58
|
+
/** Run the press as if it had been clicked. Ignored while unavailable. */
|
|
59
|
+
press(event?: Event): Promise<void>;
|
|
60
|
+
update(options: Partial<ButtonOptions>): void;
|
|
61
|
+
/** Clear a cooldown early, including its stored entry. */
|
|
62
|
+
reset(): void;
|
|
63
|
+
subscribe(listener: (state: ButtonState) => void): () => void;
|
|
64
|
+
destroy(): void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const TICK_MS = 100;
|
|
68
|
+
|
|
69
|
+
function store(cooldown: ButtonCooldown): Storage | null {
|
|
70
|
+
if (!cooldown.key || typeof window === "undefined") return null;
|
|
71
|
+
try {
|
|
72
|
+
const target = cooldown.storage === "local" ? window.localStorage : window.sessionStorage;
|
|
73
|
+
const probe = "__enigma_probe__";
|
|
74
|
+
target.setItem(probe, "1");
|
|
75
|
+
target.removeItem(probe);
|
|
76
|
+
return target;
|
|
77
|
+
} catch {
|
|
78
|
+
// Private-mode Safari exposes the object and throws on write.
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function normalize(cooldown: ButtonOptions["cooldown"]): ButtonCooldown | null {
|
|
84
|
+
if (cooldown == null) return null;
|
|
85
|
+
return typeof cooldown === "number" ? { ms: cooldown } : cooldown;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A shortcut must not fire while the visitor is writing, or it types into the page. */
|
|
89
|
+
function isTyping(target: EventTarget | null): boolean {
|
|
90
|
+
const element = target as HTMLElement | null;
|
|
91
|
+
if (!element) return false;
|
|
92
|
+
const tag = element.tagName;
|
|
93
|
+
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || element.isContentEditable === true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function createButton(options: ButtonOptions = {}): ButtonInstance {
|
|
97
|
+
let opts: ButtonOptions = { ...options };
|
|
98
|
+
let loading = Boolean(opts.loading);
|
|
99
|
+
let readyAt = 0;
|
|
100
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
101
|
+
let destroyed = false;
|
|
102
|
+
const listeners = new Set<(state: ButtonState) => void>();
|
|
103
|
+
|
|
104
|
+
const storageKey = () => {
|
|
105
|
+
const cooldown = normalize(opts.cooldown);
|
|
106
|
+
return cooldown?.key ? `enigma:cooldown:${cooldown.key}` : null;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
function restore(): void {
|
|
110
|
+
const cooldown = normalize(opts.cooldown);
|
|
111
|
+
if (!cooldown) return;
|
|
112
|
+
const target = store(cooldown);
|
|
113
|
+
const key = storageKey();
|
|
114
|
+
if (!target || !key) return;
|
|
115
|
+
const saved = Number(target.getItem(key));
|
|
116
|
+
// A stored time in the past is finished, not pending.
|
|
117
|
+
if (Number.isFinite(saved) && saved > Date.now()) readyAt = saved;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function remaining(): number {
|
|
121
|
+
return Math.max(0, readyAt - Date.now());
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function snapshot(): ButtonState {
|
|
125
|
+
const disabled = Boolean(opts.disabled);
|
|
126
|
+
const cooldown = remaining();
|
|
127
|
+
return {
|
|
128
|
+
element: opts.href ? "a" : "button",
|
|
129
|
+
available: !disabled && !loading && cooldown === 0,
|
|
130
|
+
loading,
|
|
131
|
+
disabled,
|
|
132
|
+
cooldown,
|
|
133
|
+
shortcut: opts.shortcut ?? null
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function emit(): void {
|
|
138
|
+
const state = snapshot();
|
|
139
|
+
opts.onChange?.(state);
|
|
140
|
+
for (const listener of listeners) listener(state);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function stopTicking(): void {
|
|
144
|
+
if (timer === null) return;
|
|
145
|
+
clearInterval(timer);
|
|
146
|
+
timer = null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function startTicking(): void {
|
|
150
|
+
if (timer !== null || remaining() === 0) return;
|
|
151
|
+
timer = setInterval(() => {
|
|
152
|
+
if (remaining() > 0) { emit(); return; }
|
|
153
|
+
stopTicking();
|
|
154
|
+
emit();
|
|
155
|
+
}, TICK_MS);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function beginCooldown(): void {
|
|
159
|
+
const cooldown = normalize(opts.cooldown);
|
|
160
|
+
if (!cooldown || cooldown.ms <= 0) return;
|
|
161
|
+
readyAt = Date.now() + cooldown.ms;
|
|
162
|
+
const target = store(cooldown);
|
|
163
|
+
const key = storageKey();
|
|
164
|
+
if (target && key) {
|
|
165
|
+
try { target.setItem(key, String(readyAt)); } catch { /* quota */ }
|
|
166
|
+
}
|
|
167
|
+
startTicking();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function press(event?: Event): Promise<void> {
|
|
171
|
+
if (destroyed || !snapshot().available) return;
|
|
172
|
+
const result = opts.onPress?.(event);
|
|
173
|
+
|
|
174
|
+
if (result instanceof Promise) {
|
|
175
|
+
loading = true;
|
|
176
|
+
emit();
|
|
177
|
+
try {
|
|
178
|
+
await result;
|
|
179
|
+
} finally {
|
|
180
|
+
loading = false;
|
|
181
|
+
// The cooldown starts when the work FINISHES, not when it was asked for -
|
|
182
|
+
// otherwise a slow request eats its own cooldown and the button is free
|
|
183
|
+
// again the moment it returns.
|
|
184
|
+
beginCooldown();
|
|
185
|
+
emit();
|
|
186
|
+
}
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
beginCooldown();
|
|
191
|
+
emit();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function onKeyDown(event: KeyboardEvent): void {
|
|
195
|
+
if (!opts.shortcut || isTyping(event.target)) return;
|
|
196
|
+
if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return;
|
|
197
|
+
if (event.key.toLowerCase() !== opts.shortcut.toLowerCase()) return;
|
|
198
|
+
if (!snapshot().available) return;
|
|
199
|
+
event.preventDefault();
|
|
200
|
+
void press(event);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (typeof window !== "undefined") {
|
|
204
|
+
restore();
|
|
205
|
+
startTicking();
|
|
206
|
+
window.addEventListener("keydown", onKeyDown);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
get state() { return snapshot(); },
|
|
211
|
+
press,
|
|
212
|
+
update(next: Partial<ButtonOptions>) {
|
|
213
|
+
const hadCooldown = JSON.stringify(normalize(opts.cooldown));
|
|
214
|
+
opts = { ...opts, ...next };
|
|
215
|
+
if (next.loading !== undefined) loading = Boolean(next.loading);
|
|
216
|
+
if (JSON.stringify(normalize(opts.cooldown)) !== hadCooldown) restore();
|
|
217
|
+
emit();
|
|
218
|
+
},
|
|
219
|
+
reset() {
|
|
220
|
+
readyAt = 0;
|
|
221
|
+
stopTicking();
|
|
222
|
+
const cooldown = normalize(opts.cooldown);
|
|
223
|
+
const key = storageKey();
|
|
224
|
+
if (cooldown && key) store(cooldown)?.removeItem(key);
|
|
225
|
+
emit();
|
|
226
|
+
},
|
|
227
|
+
subscribe(listener) {
|
|
228
|
+
listeners.add(listener);
|
|
229
|
+
return () => { listeners.delete(listener); };
|
|
230
|
+
},
|
|
231
|
+
destroy() {
|
|
232
|
+
destroyed = true;
|
|
233
|
+
stopTicking();
|
|
234
|
+
listeners.clear();
|
|
235
|
+
if (typeof window !== "undefined") window.removeEventListener("keydown", onKeyDown);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
}
|
package/src/core/marquee.ts
CHANGED
|
@@ -29,6 +29,20 @@ const TOUCH_HOVER_SUPPRESS_MS = 1000;
|
|
|
29
29
|
/** Below this the row is idle and the frame loop can stop. */
|
|
30
30
|
const IDLE_EPSILON = 0.01;
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* What a MOUSE resting on the row does to its speed. One option covers every case, so
|
|
34
|
+
* there is no pile of booleans to reconcile:
|
|
35
|
+
*
|
|
36
|
+
* - `"off"` ignore hover entirely (the default)
|
|
37
|
+
* - `"pause"` stop while the pointer is on it
|
|
38
|
+
* - a number multiply the cruise speed: `0.15` crawls, `1` changes nothing, `2` doubles
|
|
39
|
+
* - `{ speed }` an ABSOLUTE speed in px/s, whatever the cruise is; direction still
|
|
40
|
+
* follows `reverse`, so you give a magnitude and not a sign
|
|
41
|
+
*
|
|
42
|
+
* Touch never triggers any of it - see the pointerType note on the hover handler.
|
|
43
|
+
*/
|
|
44
|
+
export type MarqueeHover = number | "off" | "pause" | { speed: number; };
|
|
45
|
+
|
|
32
46
|
export interface MarqueeOptions {
|
|
33
47
|
/**
|
|
34
48
|
* Pixels per second. NOT a duration: a duration ties the speed to the item
|
|
@@ -41,8 +55,8 @@ export interface MarqueeOptions {
|
|
|
41
55
|
vertical?: boolean;
|
|
42
56
|
/** Allow grabbing and throwing the row. */
|
|
43
57
|
draggable?: boolean;
|
|
44
|
-
/**
|
|
45
|
-
|
|
58
|
+
/** What a mouse resting on the row does to its speed. See {@link MarqueeHover}. */
|
|
59
|
+
hover?: MarqueeHover;
|
|
46
60
|
/** Fraction of the remaining velocity gap left after one second. */
|
|
47
61
|
decay?: number;
|
|
48
62
|
/**
|
|
@@ -94,7 +108,7 @@ const DEFAULTS: ResolvedOptions = {
|
|
|
94
108
|
reverse: false,
|
|
95
109
|
vertical: false,
|
|
96
110
|
draggable: true,
|
|
97
|
-
|
|
111
|
+
hover: "off",
|
|
98
112
|
decay: DEFAULT_DECAY,
|
|
99
113
|
manageStyles: true,
|
|
100
114
|
copies: "clone"
|
|
@@ -134,11 +148,21 @@ export function createMarquee(lane: HTMLElement, track: HTMLElement, options: Ma
|
|
|
134
148
|
: null;
|
|
135
149
|
let reducedMotion = motionQuery?.matches ?? false;
|
|
136
150
|
|
|
151
|
+
/** The speed a mouse on the row asks for. One option, four shapes, no flags to reconcile. */
|
|
152
|
+
function hoverTarget(cruise: number): number {
|
|
153
|
+
const hover = opts.hover ?? "off";
|
|
154
|
+
if (hover === "off") return cruise;
|
|
155
|
+
if (hover === "pause") return 0;
|
|
156
|
+
if (typeof hover === "number") return cruise * hover;
|
|
157
|
+
// An absolute speed is a magnitude: the row keeps travelling the way it was.
|
|
158
|
+
return (cruise < 0 ? -1 : 1) * Math.abs(hover.speed);
|
|
159
|
+
}
|
|
160
|
+
|
|
137
161
|
/** Read live every frame, never captured: cruise, hover and reduced motion all ease. */
|
|
138
162
|
function target(): number {
|
|
139
163
|
if (reducedMotion || paused) return 0;
|
|
140
164
|
const cruise = opts.reverse ? -opts.speed : opts.speed;
|
|
141
|
-
return hovering ? cruise
|
|
165
|
+
return hovering ? hoverTarget(cruise) : cruise;
|
|
142
166
|
}
|
|
143
167
|
|
|
144
168
|
function axisSize(element: HTMLElement): number {
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
-
export { createMarquee, type MarqueeOptions, type MarqueeInstance } from "@/core/marquee";
|
|
1
|
+
export { createMarquee, type MarqueeOptions, type MarqueeInstance, type MarqueeHover } from "@/core/marquee";
|
|
2
2
|
export { createInput, type InputOptions, type InputInstance, type InputAction, type InputIcon, type InputActionState } from "@/core/input";
|
|
3
3
|
export { createSearch, type SearchOptions, type SearchInstance, type SearchMatch, type FuseConstructor, type FuseLike } from "@/core/search";
|
|
4
|
+
export { createButton, type ButtonOptions, type ButtonInstance, type ButtonState, type ButtonElement, type ButtonCooldown } from "@/core/button";
|
package/src/react/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export { useMarquee, type UseMarqueeResult } from "@/react/use-marquee";
|
|
2
|
-
export { type MarqueeOptions, type MarqueeInstance } from "@/core/marquee";
|
|
2
|
+
export { type MarqueeOptions, type MarqueeInstance, type MarqueeHover } from "@/core/marquee";
|
|
3
3
|
export { useInput, type UseInputResult } from "@/react/use-input";
|
|
4
4
|
export { useSearch, type UseSearchResult } from "@/react/use-search";
|
|
5
5
|
export { type InputOptions, type InputAction, type InputIcon } from "@/core/input";
|
|
6
6
|
export { type SearchOptions, type SearchMatch, type FuseConstructor } from "@/core/search";
|
|
7
|
+
export { useButton, type UseButtonResult } from "@/react/use-button";
|
|
8
|
+
export { type ButtonOptions, type ButtonState } from "@/core/button";
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { useRef, useMemo, useEffect, useState, useCallback } from "react";
|
|
2
|
+
import { createButton, type ButtonOptions, type ButtonState } from "@/core/button";
|
|
3
|
+
|
|
4
|
+
export interface UseButtonResult extends ButtonState {
|
|
5
|
+
/** Spread onto the element named by `element`. */
|
|
6
|
+
props: {
|
|
7
|
+
onClick: (event: { preventDefault(): void; }) => void;
|
|
8
|
+
"aria-disabled": boolean;
|
|
9
|
+
"aria-busy": boolean;
|
|
10
|
+
"data-loading"?: "";
|
|
11
|
+
"data-cooldown"?: "";
|
|
12
|
+
/** Only on a real button: an anchor has no `disabled`. */
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
href?: string;
|
|
15
|
+
title?: string;
|
|
16
|
+
};
|
|
17
|
+
press: () => void;
|
|
18
|
+
reset: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Button behaviour: disabled, loading, a cooldown and a keyboard shortcut collapsed into
|
|
23
|
+
* one `available`, plus the attributes that follow from it.
|
|
24
|
+
*
|
|
25
|
+
* ```tsx
|
|
26
|
+
* const { element: Tag, props, loading, cooldown } = useButton({
|
|
27
|
+
* cooldown: { ms: 30_000, key: "resend-code", storage: "local" },
|
|
28
|
+
* shortcut: "r",
|
|
29
|
+
* onPress: () => resendCode()
|
|
30
|
+
* });
|
|
31
|
+
*
|
|
32
|
+
* return <Tag {...props}>{loading ? "Sending" : cooldown ? `Wait ${Math.ceil(cooldown / 1000)}s` : "Resend"}</Tag>;
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export function useButton(options: ButtonOptions = {}): UseButtonResult {
|
|
36
|
+
const optionsRef = useRef(options);
|
|
37
|
+
optionsRef.current = options;
|
|
38
|
+
|
|
39
|
+
const instance = useMemo(() => createButton({
|
|
40
|
+
...optionsRef.current,
|
|
41
|
+
onPress: (event) => optionsRef.current.onPress?.(event),
|
|
42
|
+
onChange: (next) => optionsRef.current.onChange?.(next)
|
|
43
|
+
// Built once: recreating it would drop a running cooldown on every render.
|
|
44
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
45
|
+
}), []);
|
|
46
|
+
|
|
47
|
+
const [state, setState] = useState<ButtonState>(() => instance.state);
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const unsubscribe = instance.subscribe(setState);
|
|
51
|
+
setState(instance.state);
|
|
52
|
+
return () => {
|
|
53
|
+
unsubscribe();
|
|
54
|
+
instance.destroy();
|
|
55
|
+
};
|
|
56
|
+
}, [instance]);
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
instance.update({
|
|
60
|
+
href: options.href,
|
|
61
|
+
disabled: options.disabled,
|
|
62
|
+
loading: options.loading,
|
|
63
|
+
cooldown: options.cooldown,
|
|
64
|
+
shortcut: options.shortcut
|
|
65
|
+
});
|
|
66
|
+
}, [instance, options.href, options.disabled, options.loading, options.cooldown, options.shortcut]);
|
|
67
|
+
|
|
68
|
+
const press = useCallback(() => { void instance.press(); }, [instance]);
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
...state,
|
|
72
|
+
press,
|
|
73
|
+
reset: () => instance.reset(),
|
|
74
|
+
props: {
|
|
75
|
+
onClick: (event) => {
|
|
76
|
+
// An unavailable link still receives clicks - aria-disabled is advisory -
|
|
77
|
+
// so the press is refused here rather than relying on the attribute.
|
|
78
|
+
if (!state.available) { event.preventDefault(); return; }
|
|
79
|
+
void instance.press();
|
|
80
|
+
},
|
|
81
|
+
"aria-disabled": !state.available,
|
|
82
|
+
"aria-busy": state.loading,
|
|
83
|
+
...(state.loading ? { "data-loading": "" as const } : {}),
|
|
84
|
+
...(state.cooldown > 0 ? { "data-cooldown": "" as const } : {}),
|
|
85
|
+
...(state.element === "button" ? { disabled: state.disabled } : { href: options.href }),
|
|
86
|
+
...(state.shortcut ? { title: `Shortcut: ${state.shortcut.toUpperCase()}` } : {})
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}
|
package/src/react/use-marquee.ts
CHANGED
|
@@ -29,7 +29,7 @@ export interface UseMarqueeResult {
|
|
|
29
29
|
* to the item count, so the row accelerates every time content is added.
|
|
30
30
|
*
|
|
31
31
|
* ```tsx
|
|
32
|
-
* const { laneRef, trackRef, copies } = useMarquee({ speed: 80,
|
|
32
|
+
* const { laneRef, trackRef, copies } = useMarquee({ speed: 80, hover: 0.15 });
|
|
33
33
|
* return (
|
|
34
34
|
* <div ref={laneRef}>
|
|
35
35
|
* <div ref={trackRef} style={{ display: "flex" }}>
|
|
@@ -87,7 +87,7 @@ export function useMarquee(options: MarqueeOptions = {}): UseMarqueeResult {
|
|
|
87
87
|
|
|
88
88
|
useIsomorphicLayoutEffect(() => {
|
|
89
89
|
instanceRef.current?.update(options);
|
|
90
|
-
}, [options.speed, options.reverse, options.vertical, options.draggable, options.
|
|
90
|
+
}, [options.speed, options.reverse, options.vertical, options.draggable, options.hover, options.decay]);
|
|
91
91
|
|
|
92
92
|
// A copy count change means new children; the lap has to be read again.
|
|
93
93
|
useIsomorphicLayoutEffect(() => {
|