@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.
@@ -11,7 +11,7 @@ var DEFAULTS = {
11
11
  reverse: false,
12
12
  vertical: false,
13
13
  draggable: true,
14
- hoverScale: 1,
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 * opts.hoverScale : 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
- export { createInput, createMarquee };
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
- /** Speed multiplier while a MOUSE rests on the row. 0 pauses it. */
27
- hoverScale?: number;
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
- export { type InputAction, type InputActionState, type InputIcon, type InputInstance, type InputOptions, type MarqueeInstance, type MarqueeOptions, createInput, createMarquee };
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-W2DRHYC2.js';
1
+ export { createButton, createInput, createMarquee } from './chunk-WCB7V7XO.js';
2
2
  export { createSearch } from './chunk-UZFEEFMF.js';
@@ -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, hoverScale: 0.15 });
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
- export { InputOptions, MarqueeOptions, SearchMatch, SearchOptions, type UseInputResult, type UseMarqueeResult, type UseSearchResult, useInput, useMarquee, useSearch };
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 };
@@ -1,6 +1,6 @@
1
- import { createMarquee, createInput } from '../chunk-W2DRHYC2.js';
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.hoverScale, options.decay]);
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.4.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",
@@ -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
- /** Speed multiplier while a mouse rests on the row. 0 pauses it. */
16
- hoverScale?: number;
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
- hoverScale = 1,
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, hoverScale, reverse });
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
- /** Speed multiplier while a mouse rests on the row. 0 pauses it. */
20
- hoverScale?: number;
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
- hoverScale = 1,
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, hoverScale, reverse });
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
+ }
@@ -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
- /** Speed multiplier while a MOUSE rests on the row. 0 pauses it. */
45
- hoverScale?: number;
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
- hoverScale: 1,
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 * opts.hoverScale : 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";
@@ -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
+ }
@@ -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, hoverScale: 0.15 });
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.hoverScale, options.decay]);
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(() => {