@enigmax/primitives 0.5.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.
@@ -477,4 +477,164 @@ function createInput(input, options = {}) {
477
477
  };
478
478
  }
479
479
 
480
- 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
@@ -162,4 +162,66 @@ interface InputInstance {
162
162
  */
163
163
  declare function createInput(input: HTMLInputElement, options?: InputOptions): InputInstance;
164
164
 
165
- export { type InputAction, type InputActionState, type InputIcon, type InputInstance, type InputOptions, type MarqueeHover, 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-2DVVWPHM.js';
1
+ export { createButton, createInput, createMarquee } from './chunk-WCB7V7XO.js';
2
2
  export { createSearch } from './chunk-UZFEEFMF.js';
@@ -1,5 +1,5 @@
1
1
  import { RefObject } from 'react';
2
- import { MarqueeOptions, InputOptions } from '../index.js';
2
+ import { MarqueeOptions, InputOptions, ButtonState, ButtonOptions } from '../index.js';
3
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';
@@ -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-2DVVWPHM.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 = {}) {
@@ -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.5.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/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/index.ts CHANGED
@@ -1,3 +1,4 @@
1
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";
@@ -4,3 +4,5 @@ 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
+ }