@refraktor/utils 0.0.3 → 0.0.5

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.
@@ -6,4 +6,5 @@ export { useMergedRefs } from "./use-merged-refs";
6
6
  export { useUncontrolled } from "./use-uncontrolled";
7
7
  export { useDebouncedCallback } from "./use-debounced-callback";
8
8
  export { useIsomorphicLayoutEffect } from "./use-isomorphic-layout-effect";
9
+ export { useKeybind } from "./use-keybind";
9
10
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/hooks/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,UAAU,CAAC;AACjC,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACjD,OAAO,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AACtD,OAAO,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAC1D,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAClD,OAAO,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AACrD,OAAO,EAAE,oBAAoB,EAAE,MAAM,0BAA0B,CAAC;AAChE,OAAO,EAAE,yBAAyB,EAAE,MAAM,gCAAgC,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/hooks/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,UAAU,CAAC;AACjC,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACjD,OAAO,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AACtD,OAAO,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAC1D,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAClD,OAAO,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AACrD,OAAO,EAAE,oBAAoB,EAAE,MAAM,0BAA0B,CAAC;AAChE,OAAO,EAAE,yBAAyB,EAAE,MAAM,gCAAgC,CAAC;AAC3E,OAAO,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC"}
@@ -6,3 +6,4 @@ export { useMergedRefs } from "./use-merged-refs";
6
6
  export { useUncontrolled } from "./use-uncontrolled";
7
7
  export { useDebouncedCallback } from "./use-debounced-callback";
8
8
  export { useIsomorphicLayoutEffect } from "./use-isomorphic-layout-effect";
9
+ export { useKeybind } from "./use-keybind";
@@ -0,0 +1,12 @@
1
+ export type UseKeybindEvent = "keydown" | "keyup";
2
+ export interface UseKeybindOptions {
3
+ enabled?: boolean;
4
+ event?: UseKeybindEvent;
5
+ target?: Window | Document | HTMLElement | null;
6
+ preventDefault?: boolean;
7
+ stopPropagation?: boolean;
8
+ ignoreInputFields?: boolean;
9
+ allowRepeat?: boolean;
10
+ }
11
+ export declare function useKeybind(combo: string, handler: (event: KeyboardEvent) => void, options?: UseKeybindOptions): void;
12
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/hooks/use-keybind/index.ts"],"names":[],"mappings":"AASA,MAAM,MAAM,eAAe,GAAG,SAAS,GAAG,OAAO,CAAC;AAElD,MAAM,WAAW,iBAAiB;IAC9B,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,KAAK,CAAC,EAAE,eAAe,CAAC;IACxB,MAAM,CAAC,EAAE,MAAM,GAAG,QAAQ,GAAG,WAAW,GAAG,IAAI,CAAC;IAChD,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,WAAW,CAAC,EAAE,OAAO,CAAC;CACzB;AAqGD,wBAAgB,UAAU,CACtB,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,EACvC,OAAO,GAAE,iBAAsB,GAChC,IAAI,CAkEN"}
@@ -0,0 +1,126 @@
1
+ import { useEffect, useMemo, useRef } from "react";
2
+ const MODIFIER_ALIASES = {
3
+ ctrl: "ctrl",
4
+ control: "ctrl",
5
+ shift: "shift",
6
+ alt: "alt",
7
+ option: "alt",
8
+ meta: "meta",
9
+ cmd: "meta",
10
+ command: "meta",
11
+ super: "meta",
12
+ win: "meta"
13
+ };
14
+ const KEY_ALIASES = {
15
+ esc: "escape",
16
+ return: "enter",
17
+ del: "delete",
18
+ plus: "+",
19
+ space: " ",
20
+ spacebar: " "
21
+ };
22
+ function normalizeToken(token) {
23
+ return token.trim().toLowerCase();
24
+ }
25
+ function normalizeKey(key) {
26
+ const normalized = key.trim().toLowerCase();
27
+ return KEY_ALIASES[normalized] ?? normalized;
28
+ }
29
+ function parseCombo(combo) {
30
+ const tokens = combo
31
+ .split("+")
32
+ .map((token) => normalizeToken(token))
33
+ .filter(Boolean);
34
+ if (tokens.length === 0) {
35
+ return null;
36
+ }
37
+ const modifiers = new Set();
38
+ let key = null;
39
+ for (const token of tokens) {
40
+ const modifier = MODIFIER_ALIASES[token];
41
+ if (modifier) {
42
+ modifiers.add(modifier);
43
+ continue;
44
+ }
45
+ if (key !== null) {
46
+ return null;
47
+ }
48
+ key = normalizeKey(token);
49
+ }
50
+ return { modifiers, key };
51
+ }
52
+ function isTypingTarget(target) {
53
+ if (!(target instanceof HTMLElement)) {
54
+ return false;
55
+ }
56
+ if (target.isContentEditable) {
57
+ return true;
58
+ }
59
+ const tagName = target.tagName;
60
+ return tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT";
61
+ }
62
+ function matchesCombo(event, parsed) {
63
+ if (event.ctrlKey !== parsed.modifiers.has("ctrl")) {
64
+ return false;
65
+ }
66
+ if (event.shiftKey !== parsed.modifiers.has("shift")) {
67
+ return false;
68
+ }
69
+ if (event.altKey !== parsed.modifiers.has("alt")) {
70
+ return false;
71
+ }
72
+ if (event.metaKey !== parsed.modifiers.has("meta")) {
73
+ return false;
74
+ }
75
+ if (parsed.key === null) {
76
+ return true;
77
+ }
78
+ return normalizeKey(event.key) === parsed.key;
79
+ }
80
+ export function useKeybind(combo, handler, options = {}) {
81
+ const { enabled = true, event: eventName = "keydown", target = typeof window !== "undefined" ? window : null, preventDefault = false, stopPropagation = false, ignoreInputFields = true, allowRepeat = false } = options;
82
+ const parsed = useMemo(() => parseCombo(combo), [combo]);
83
+ const handlerRef = useRef(handler);
84
+ useEffect(() => {
85
+ handlerRef.current = handler;
86
+ }, [handler]);
87
+ useEffect(() => {
88
+ if (!enabled || !target || parsed === null) {
89
+ return;
90
+ }
91
+ const listener = (keyboardEvent) => {
92
+ if (!(keyboardEvent instanceof KeyboardEvent)) {
93
+ return;
94
+ }
95
+ if (!allowRepeat && keyboardEvent.repeat) {
96
+ return;
97
+ }
98
+ if (ignoreInputFields && isTypingTarget(keyboardEvent.target)) {
99
+ return;
100
+ }
101
+ if (!matchesCombo(keyboardEvent, parsed)) {
102
+ return;
103
+ }
104
+ if (preventDefault) {
105
+ keyboardEvent.preventDefault();
106
+ }
107
+ if (stopPropagation) {
108
+ keyboardEvent.stopPropagation();
109
+ }
110
+ handlerRef.current(keyboardEvent);
111
+ };
112
+ target.addEventListener(eventName, listener);
113
+ return () => {
114
+ target.removeEventListener(eventName, listener);
115
+ };
116
+ }, [
117
+ allowRepeat,
118
+ enabled,
119
+ eventName,
120
+ ignoreInputFields,
121
+ parsed,
122
+ preventDefault,
123
+ stopPropagation,
124
+ target
125
+ ]);
126
+ }
@@ -0,0 +1,31 @@
1
+ export interface KeyboardLikeEvent {
2
+ key: string;
3
+ altKey: boolean;
4
+ ctrlKey: boolean;
5
+ metaKey: boolean;
6
+ shiftKey: boolean;
7
+ repeat: boolean;
8
+ defaultPrevented: boolean;
9
+ preventDefault: () => void;
10
+ stopPropagation: () => void;
11
+ }
12
+ export type KeybindCombo = string;
13
+ export interface KeybindMatchOptions {
14
+ exactModifiers?: boolean;
15
+ }
16
+ export interface KeybindBinding<E extends KeyboardLikeEvent = KeyboardLikeEvent> {
17
+ combo: KeybindCombo | KeybindCombo[];
18
+ handler: (event: E) => void;
19
+ preventDefault?: boolean;
20
+ stopPropagation?: boolean;
21
+ allowRepeat?: boolean;
22
+ when?: boolean | ((event: E) => boolean);
23
+ exactModifiers?: boolean;
24
+ }
25
+ export interface CreateKeybindHandlerOptions {
26
+ skipIfDefaultPrevented?: boolean;
27
+ stopAtFirstMatch?: boolean;
28
+ }
29
+ export declare function matchesKeybind(event: KeyboardLikeEvent, combo: KeybindCombo, options?: KeybindMatchOptions): boolean;
30
+ export declare function createKeybindHandler<E extends KeyboardLikeEvent = KeyboardLikeEvent>(bindings: KeybindBinding<E>[], options?: CreateKeybindHandlerOptions): (event: E) => void;
31
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/utils/keybind/index.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,iBAAiB;IAC9B,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,OAAO,CAAC;IAChB,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,OAAO,CAAC;IAClB,MAAM,EAAE,OAAO,CAAC;IAChB,gBAAgB,EAAE,OAAO,CAAC;IAC1B,cAAc,EAAE,MAAM,IAAI,CAAC;IAC3B,eAAe,EAAE,MAAM,IAAI,CAAC;CAC/B;AAED,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC;AAElC,MAAM,WAAW,mBAAmB;IAChC,cAAc,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED,MAAM,WAAW,cAAc,CAAC,CAAC,SAAS,iBAAiB,GAAG,iBAAiB;IAC3E,KAAK,EAAE,YAAY,GAAG,YAAY,EAAE,CAAC;IACrC,OAAO,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,IAAI,CAAC;IAC5B,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,IAAI,CAAC,EAAE,OAAO,GAAG,CAAC,CAAC,KAAK,EAAE,CAAC,KAAK,OAAO,CAAC,CAAC;IACzC,cAAc,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED,MAAM,WAAW,2BAA2B;IACxC,sBAAsB,CAAC,EAAE,OAAO,CAAC;IACjC,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC9B;AA8LD,wBAAgB,cAAc,CAC1B,KAAK,EAAE,iBAAiB,EACxB,KAAK,EAAE,YAAY,EACnB,OAAO,GAAE,mBAAwB,GAClC,OAAO,CAQT;AAED,wBAAgB,oBAAoB,CAChC,CAAC,SAAS,iBAAiB,GAAG,iBAAiB,EAE/C,QAAQ,EAAE,cAAc,CAAC,CAAC,CAAC,EAAE,EAC7B,OAAO,GAAE,2BAAgC,GAC1C,CAAC,KAAK,EAAE,CAAC,KAAK,IAAI,CA4EpB"}
@@ -0,0 +1,192 @@
1
+ const KEY_ALIASES = {
2
+ esc: "Escape",
3
+ escape: "Escape",
4
+ enter: "Enter",
5
+ return: "Enter",
6
+ space: " ",
7
+ spacebar: " ",
8
+ left: "ArrowLeft",
9
+ right: "ArrowRight",
10
+ up: "ArrowUp",
11
+ down: "ArrowDown",
12
+ del: "Delete",
13
+ plus: "+"
14
+ };
15
+ const keybindCache = new Map();
16
+ const isApplePlatform = () => {
17
+ if (typeof navigator === "undefined") {
18
+ return false;
19
+ }
20
+ const platform = navigator.platform || navigator.userAgent;
21
+ return /Mac|iPod|iPhone|iPad/i.test(platform);
22
+ };
23
+ const normalizeKey = (value) => {
24
+ const key = value.length === 1 ? value : value.trim();
25
+ if (key.length === 0) {
26
+ return "";
27
+ }
28
+ const aliased = KEY_ALIASES[key.toLowerCase()];
29
+ if (aliased) {
30
+ return aliased;
31
+ }
32
+ if (key.length === 1) {
33
+ return key.toLowerCase();
34
+ }
35
+ return key;
36
+ };
37
+ const parseKeybind = (combo) => {
38
+ const cached = keybindCache.get(combo);
39
+ if (cached !== undefined) {
40
+ return cached;
41
+ }
42
+ const tokens = combo
43
+ .split("+")
44
+ .map((token) => token.trim())
45
+ .filter((token) => token.length > 0);
46
+ if (tokens.length === 0) {
47
+ keybindCache.set(combo, null);
48
+ return null;
49
+ }
50
+ const parsed = {
51
+ key: "",
52
+ alt: false,
53
+ ctrl: false,
54
+ meta: false,
55
+ shift: false,
56
+ mod: false
57
+ };
58
+ for (const token of tokens) {
59
+ const normalizedToken = token.toLowerCase();
60
+ if (normalizedToken === "alt" || normalizedToken === "option") {
61
+ parsed.alt = true;
62
+ continue;
63
+ }
64
+ if (normalizedToken === "ctrl" ||
65
+ normalizedToken === "control" ||
66
+ normalizedToken === "ctl") {
67
+ parsed.ctrl = true;
68
+ continue;
69
+ }
70
+ if (normalizedToken === "meta" ||
71
+ normalizedToken === "cmd" ||
72
+ normalizedToken === "command") {
73
+ parsed.meta = true;
74
+ continue;
75
+ }
76
+ if (normalizedToken === "shift") {
77
+ parsed.shift = true;
78
+ continue;
79
+ }
80
+ if (normalizedToken === "mod") {
81
+ parsed.mod = true;
82
+ continue;
83
+ }
84
+ if (parsed.key.length > 0) {
85
+ keybindCache.set(combo, null);
86
+ return null;
87
+ }
88
+ parsed.key = normalizeKey(token);
89
+ }
90
+ if (parsed.key.length === 0) {
91
+ keybindCache.set(combo, null);
92
+ return null;
93
+ }
94
+ keybindCache.set(combo, parsed);
95
+ return parsed;
96
+ };
97
+ const matchesParsedKeybind = (event, parsed, options = {}) => {
98
+ const eventKey = normalizeKey(event.key);
99
+ if (eventKey !== parsed.key) {
100
+ return false;
101
+ }
102
+ const applePlatform = isApplePlatform();
103
+ const requiresCtrl = parsed.ctrl || (parsed.mod && !applePlatform);
104
+ const requiresMeta = parsed.meta || (parsed.mod && applePlatform);
105
+ if (parsed.alt && !event.altKey) {
106
+ return false;
107
+ }
108
+ if (parsed.shift && !event.shiftKey) {
109
+ return false;
110
+ }
111
+ if (requiresCtrl && !event.ctrlKey) {
112
+ return false;
113
+ }
114
+ if (requiresMeta && !event.metaKey) {
115
+ return false;
116
+ }
117
+ if (!options.exactModifiers) {
118
+ return true;
119
+ }
120
+ if (!parsed.alt && event.altKey) {
121
+ return false;
122
+ }
123
+ if (!parsed.shift && event.shiftKey) {
124
+ return false;
125
+ }
126
+ if (!requiresCtrl && event.ctrlKey) {
127
+ return false;
128
+ }
129
+ if (!requiresMeta && event.metaKey) {
130
+ return false;
131
+ }
132
+ return true;
133
+ };
134
+ export function matchesKeybind(event, combo, options = {}) {
135
+ const parsed = parseKeybind(combo);
136
+ if (!parsed) {
137
+ return false;
138
+ }
139
+ return matchesParsedKeybind(event, parsed, options);
140
+ }
141
+ export function createKeybindHandler(bindings, options = {}) {
142
+ const { skipIfDefaultPrevented = true, stopAtFirstMatch = true } = options;
143
+ const preparedBindings = bindings
144
+ .map((binding) => {
145
+ const combos = Array.isArray(binding.combo)
146
+ ? binding.combo
147
+ : [binding.combo];
148
+ const parsedCombos = combos
149
+ .map(parseKeybind)
150
+ .filter((combo) => combo !== null);
151
+ if (parsedCombos.length === 0) {
152
+ return null;
153
+ }
154
+ return {
155
+ ...binding,
156
+ parsedCombos
157
+ };
158
+ })
159
+ .filter((binding) => binding !== null);
160
+ return (event) => {
161
+ if (skipIfDefaultPrevented && event.defaultPrevented) {
162
+ return;
163
+ }
164
+ for (const binding of preparedBindings) {
165
+ if (binding.allowRepeat === false && event.repeat) {
166
+ continue;
167
+ }
168
+ const shouldHandle = typeof binding.when === "function"
169
+ ? binding.when(event)
170
+ : binding.when;
171
+ if (shouldHandle === false) {
172
+ continue;
173
+ }
174
+ const isMatch = binding.parsedCombos.some((combo) => matchesParsedKeybind(event, combo, {
175
+ exactModifiers: binding.exactModifiers
176
+ }));
177
+ if (!isMatch) {
178
+ continue;
179
+ }
180
+ if (binding.preventDefault) {
181
+ event.preventDefault();
182
+ }
183
+ if (binding.stopPropagation) {
184
+ event.stopPropagation();
185
+ }
186
+ binding.handler(event);
187
+ if (stopAtFirstMatch) {
188
+ break;
189
+ }
190
+ }
191
+ };
192
+ }
package/package.json CHANGED
@@ -1,10 +1,31 @@
1
1
  {
2
2
  "name": "@refraktor/utils",
3
- "version": "0.0.3",
3
+ "description": "Shared React hooks and utilities for Refraktor",
4
+ "version": "0.0.5",
4
5
  "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/refraktorui/refraktor.git",
9
+ "directory": "packages/utils"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/refraktorui/refraktor/issues"
13
+ },
14
+ "keywords": [
15
+ "react",
16
+ "hooks",
17
+ "utilities",
18
+ "utils"
19
+ ],
5
20
  "publishConfig": {
6
21
  "access": "public"
7
22
  },
23
+ "sideEffects": false,
24
+ "files": [
25
+ "build",
26
+ "README.md",
27
+ "LICENSE"
28
+ ],
8
29
  "main": "./build/index.js",
9
30
  "module": "./build/index.js",
10
31
  "types": "./build/index.d.ts",
@@ -23,7 +44,7 @@
23
44
  "react": ">=18.0.0"
24
45
  },
25
46
  "devDependencies": {
26
- "@types/react": "^19.2.7",
47
+ "@types/react": "^19.2.14",
27
48
  "typescript": "^5.9.3"
28
49
  }
29
50
  }
@@ -1 +0,0 @@
1
- $ tsc
Binary file
@@ -1,8 +0,0 @@
1
- export { useId } from "./use-id";
2
- export { useDisclosure } from "./use-disclosure";
3
- export { useClickOutside } from "./use-click-outside";
4
- export { useResizeObserver } from "./use-resize-observer";
5
- export { useMergedRefs } from "./use-merged-refs";
6
- export { useUncontrolled } from "./use-uncontrolled";
7
- export { useDebouncedCallback } from "./use-debounced-callback";
8
- export { useIsomorphicLayoutEffect } from "./use-isomorphic-layout-effect";
@@ -1,76 +0,0 @@
1
- import { useEffect, useRef } from "react";
2
-
3
- export type UseClickOutsideEvent = keyof DocumentEventMap;
4
-
5
- type UseClickOutsideNode = HTMLElement | null;
6
-
7
- const DEFAULT_EVENTS: UseClickOutsideEvent[] = ["mousedown", "touchstart"];
8
- const DEFAULT_NODES: UseClickOutsideNode[] = [];
9
-
10
- export function useClickOutside<T extends HTMLElement = HTMLElement>(
11
- handler: (event: Event) => void,
12
- events: UseClickOutsideEvent[] = DEFAULT_EVENTS,
13
- nodes: UseClickOutsideNode[] = DEFAULT_NODES
14
- ) {
15
- const ref = useRef<T>(null);
16
- const handlerRef = useRef(handler);
17
-
18
- useEffect(() => {
19
- handlerRef.current = handler;
20
- }, [handler]);
21
-
22
- useEffect(() => {
23
- const listener = (event: Event) => {
24
- const target = event.target;
25
-
26
- if (!(target instanceof Node)) {
27
- return;
28
- }
29
-
30
- const element = ref.current;
31
-
32
- if (!element) {
33
- return;
34
- }
35
-
36
- const path =
37
- typeof event.composedPath === "function"
38
- ? event.composedPath()
39
- : undefined;
40
-
41
- const isInsideElement = path
42
- ? path.includes(element)
43
- : element.contains(target);
44
-
45
- if (isInsideElement) {
46
- return;
47
- }
48
-
49
- const isInsideAdditionalNode = nodes.some((node) => {
50
- if (!node) {
51
- return false;
52
- }
53
-
54
- return path ? path.includes(node) : node.contains(target);
55
- });
56
-
57
- if (isInsideAdditionalNode) {
58
- return;
59
- }
60
-
61
- handlerRef.current(event);
62
- };
63
-
64
- for (const eventName of events) {
65
- document.addEventListener(eventName, listener);
66
- }
67
-
68
- return () => {
69
- for (const eventName of events) {
70
- document.removeEventListener(eventName, listener);
71
- }
72
- };
73
- }, [events, nodes]);
74
-
75
- return ref;
76
- }
@@ -1,128 +0,0 @@
1
- import { useCallback, useEffect, useMemo, useRef } from "react";
2
-
3
- export interface UseDebouncedCallbackOptions {
4
- leading?: boolean;
5
- trailing?: boolean;
6
- }
7
-
8
- export type UseDebouncedCallbackReturn<
9
- TArgs extends unknown[],
10
- TResult
11
- > = ((...args: TArgs) => void) & {
12
- cancel: () => void;
13
- flush: () => TResult | undefined;
14
- isPending: () => boolean;
15
- };
16
-
17
- export function useDebouncedCallback<TArgs extends unknown[], TResult>(
18
- callback: (...args: TArgs) => TResult,
19
- delay: number,
20
- options: UseDebouncedCallbackOptions = {}
21
- ): UseDebouncedCallbackReturn<TArgs, TResult> {
22
- const { leading = false, trailing = true } = options;
23
- const wait = Math.max(0, delay);
24
-
25
- const callbackRef = useRef(callback);
26
- const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
27
- const lastArgsRef = useRef<TArgs | null>(null);
28
- const shouldCallTrailingRef = useRef(false);
29
- const resultRef = useRef<TResult | undefined>(undefined);
30
-
31
- useEffect(() => {
32
- callbackRef.current = callback;
33
- }, [callback]);
34
-
35
- const invoke = useCallback((args: TArgs) => {
36
- const result = callbackRef.current(...args);
37
- resultRef.current = result;
38
- return result;
39
- }, []);
40
-
41
- const clearTimer = useCallback(() => {
42
- if (timeoutRef.current !== null) {
43
- clearTimeout(timeoutRef.current);
44
- timeoutRef.current = null;
45
- }
46
- }, []);
47
-
48
- const cancel = useCallback(() => {
49
- clearTimer();
50
- shouldCallTrailingRef.current = false;
51
- lastArgsRef.current = null;
52
- }, [clearTimer]);
53
-
54
- const flush = useCallback(() => {
55
- if (timeoutRef.current === null) {
56
- return resultRef.current;
57
- }
58
-
59
- clearTimer();
60
-
61
- if (
62
- trailing &&
63
- shouldCallTrailingRef.current &&
64
- lastArgsRef.current !== null
65
- ) {
66
- const next = invoke(lastArgsRef.current);
67
- shouldCallTrailingRef.current = false;
68
- lastArgsRef.current = null;
69
- return next;
70
- }
71
-
72
- shouldCallTrailingRef.current = false;
73
- lastArgsRef.current = null;
74
- return resultRef.current;
75
- }, [clearTimer, invoke, trailing]);
76
-
77
- const isPending = useCallback(() => timeoutRef.current !== null, []);
78
-
79
- const debounced = useMemo(() => {
80
- const fn = (...args: TArgs) => {
81
- if (!leading && !trailing) {
82
- return;
83
- }
84
-
85
- const hasTimer = timeoutRef.current !== null;
86
-
87
- if (!hasTimer) {
88
- if (leading) {
89
- invoke(args);
90
- shouldCallTrailingRef.current = false;
91
- lastArgsRef.current = null;
92
- } else {
93
- shouldCallTrailingRef.current = true;
94
- lastArgsRef.current = args;
95
- }
96
- } else if (trailing) {
97
- shouldCallTrailingRef.current = true;
98
- lastArgsRef.current = args;
99
- }
100
-
101
- clearTimer();
102
- timeoutRef.current = setTimeout(() => {
103
- timeoutRef.current = null;
104
-
105
- if (
106
- trailing &&
107
- shouldCallTrailingRef.current &&
108
- lastArgsRef.current !== null
109
- ) {
110
- invoke(lastArgsRef.current);
111
- }
112
-
113
- shouldCallTrailingRef.current = false;
114
- lastArgsRef.current = null;
115
- }, wait);
116
- };
117
-
118
- return Object.assign(fn, {
119
- cancel,
120
- flush,
121
- isPending
122
- });
123
- }, [cancel, clearTimer, flush, invoke, isPending, leading, trailing, wait]);
124
-
125
- useEffect(() => cancel, [cancel]);
126
-
127
- return debounced;
128
- }
@@ -1,67 +0,0 @@
1
- import { useCallback, useMemo, useState } from "react";
2
-
3
- export interface UseDisclosureCallbacks {
4
- onOpen?: () => void;
5
- onClose?: () => void;
6
- }
7
-
8
- export interface UseDisclosureHandlers {
9
- open: () => void;
10
- close: () => void;
11
- toggle: () => void;
12
- }
13
-
14
- export type UseDisclosureReturn = [boolean, UseDisclosureHandlers];
15
-
16
- export function useDisclosure(
17
- initialState = false,
18
- callbacks?: UseDisclosureCallbacks
19
- ): UseDisclosureReturn {
20
- const [opened, setOpened] = useState(initialState);
21
- const { onOpen, onClose } = callbacks ?? {};
22
-
23
- const open = useCallback(() => {
24
- setOpened((isOpened) => {
25
- if (!isOpened) {
26
- onOpen?.();
27
- }
28
-
29
- return true;
30
- });
31
- }, [onOpen]);
32
-
33
- const close = useCallback(() => {
34
- setOpened((isOpened) => {
35
- if (isOpened) {
36
- onClose?.();
37
- }
38
-
39
- return false;
40
- });
41
- }, [onClose]);
42
-
43
- const toggle = useCallback(() => {
44
- setOpened((isOpened) => {
45
- const next = !isOpened;
46
-
47
- if (next) {
48
- onOpen?.();
49
- } else {
50
- onClose?.();
51
- }
52
-
53
- return next;
54
- });
55
- }, [onOpen, onClose]);
56
-
57
- const handlers = useMemo(
58
- () => ({
59
- open,
60
- close,
61
- toggle
62
- }),
63
- [open, close, toggle]
64
- );
65
-
66
- return [opened, handlers];
67
- }
@@ -1,6 +0,0 @@
1
- import { useId as useReactId } from "react";
2
-
3
- export function useId(propId?: any): string {
4
- const id = useReactId();
5
- return propId ?? id;
6
- }
@@ -1,4 +0,0 @@
1
- import { useEffect, useLayoutEffect } from "react";
2
-
3
- export const useIsomorphicLayoutEffect: typeof useLayoutEffect =
4
- typeof window !== "undefined" ? useLayoutEffect : useEffect;
@@ -1,45 +0,0 @@
1
- import { Ref, useCallback, RefCallback } from "react";
2
-
3
- type AnyRef<T> = Ref<T> | undefined;
4
- type CleanupFunction<T> = ReturnType<RefCallback<T>>;
5
-
6
- function applyRef<T>(ref: AnyRef<T>, value: T | null): CleanupFunction<T> {
7
- if (!ref) return;
8
-
9
- if (typeof ref === "function") {
10
- return ref(value);
11
- }
12
-
13
- if (typeof ref === "object" && "current" in ref) {
14
- ref.current = value;
15
- }
16
- }
17
-
18
- export function mergeRefs<T>(...refs: AnyRef<T>[]): RefCallback<T> {
19
- const cleanups = new Map<AnyRef<T>, Exclude<CleanupFunction<T>, void>>();
20
-
21
- return (node: T | null) => {
22
- for (const ref of refs) {
23
- const cleanup = applyRef(ref, node);
24
- if (cleanup) cleanups.set(ref, cleanup);
25
- }
26
-
27
- if (cleanups.size > 0) {
28
- return () => {
29
- for (const ref of refs) {
30
- const fn = cleanups.get(ref);
31
- if (typeof fn === "function") {
32
- fn();
33
- } else {
34
- applyRef(ref, null);
35
- }
36
- }
37
- cleanups.clear();
38
- };
39
- }
40
- };
41
- }
42
-
43
- export function useMergedRefs<T>(...refs: AnyRef<T>[]) {
44
- return useCallback(mergeRefs(...refs), refs);
45
- }
@@ -1,105 +0,0 @@
1
- import { useCallback, useEffect, useRef, useState } from "react";
2
-
3
- export interface UseResizeObserverSize {
4
- width: number;
5
- height: number;
6
- }
7
-
8
- export interface UseResizeObserverOptions {
9
- initialSize?: UseResizeObserverSize;
10
- box?: ResizeObserverBoxOptions;
11
- onResize?: (entry: ResizeObserverEntry) => void;
12
- }
13
-
14
- export interface UseResizeObserverReturn<T extends HTMLElement = HTMLElement>
15
- extends UseResizeObserverSize {
16
- ref: (node: T | null) => void;
17
- entry: ResizeObserverEntry | null;
18
- }
19
-
20
- interface ResizeObserverState extends UseResizeObserverSize {
21
- entry: ResizeObserverEntry | null;
22
- }
23
-
24
- const DEFAULT_SIZE: UseResizeObserverSize = {
25
- width: 0,
26
- height: 0
27
- };
28
-
29
- export function useResizeObserver<T extends HTMLElement = HTMLElement>(
30
- options: UseResizeObserverOptions = {}
31
- ): UseResizeObserverReturn<T> {
32
- const { initialSize = DEFAULT_SIZE, box, onResize } = options;
33
- const [node, setNode] = useState<T | null>(null);
34
- const [state, setState] = useState<ResizeObserverState>(() => ({
35
- width: initialSize.width,
36
- height: initialSize.height,
37
- entry: null
38
- }));
39
- const onResizeRef = useRef(onResize);
40
-
41
- useEffect(() => {
42
- onResizeRef.current = onResize;
43
- }, [onResize]);
44
-
45
- const ref = useCallback((nextNode: T | null) => {
46
- setNode(nextNode);
47
- }, []);
48
-
49
- useEffect(() => {
50
- if (!node) {
51
- return;
52
- }
53
-
54
- if (
55
- typeof window === "undefined" ||
56
- typeof ResizeObserver === "undefined"
57
- ) {
58
- return;
59
- }
60
-
61
- const observer = new ResizeObserver(([entry]) => {
62
- if (!entry) {
63
- return;
64
- }
65
-
66
- const nextWidth = entry.contentRect.width;
67
- const nextHeight = entry.contentRect.height;
68
-
69
- setState((current) => {
70
- if (
71
- current.width === nextWidth &&
72
- current.height === nextHeight &&
73
- current.entry !== null
74
- ) {
75
- return current;
76
- }
77
-
78
- return {
79
- width: nextWidth,
80
- height: nextHeight,
81
- entry
82
- };
83
- });
84
-
85
- onResizeRef.current?.(entry);
86
- });
87
-
88
- try {
89
- observer.observe(node, box ? { box } : undefined);
90
- } catch {
91
- observer.observe(node);
92
- }
93
-
94
- return () => {
95
- observer.disconnect();
96
- };
97
- }, [box, node]);
98
-
99
- return {
100
- ref,
101
- width: state.width,
102
- height: state.height,
103
- entry: state.entry
104
- };
105
- }
@@ -1,36 +0,0 @@
1
- import { useState } from "react";
2
-
3
- export type UseUncontrolledProps<T> = {
4
- value?: T;
5
- defaultValue?: T;
6
- finalValue?: T;
7
- onChange?: (value: T, ...payload: any[]) => void;
8
- };
9
-
10
- export type UseUncontrolledReturn<T> = [
11
- T,
12
- (value: T, ...payload: any[]) => void,
13
- boolean
14
- ];
15
-
16
- export function useUncontrolled<T>({
17
- value,
18
- defaultValue,
19
- finalValue,
20
- onChange = () => {}
21
- }: UseUncontrolledProps<T>): UseUncontrolledReturn<T> {
22
- const [state, setState] = useState(
23
- defaultValue !== undefined ? defaultValue : finalValue
24
- );
25
-
26
- const handleChange = (val: T, ...payload: any[]) => {
27
- setState(val);
28
- onChange?.(val, ...payload);
29
- };
30
-
31
- if (value !== undefined) {
32
- return [value as T, onChange, true];
33
- }
34
-
35
- return [state as T, handleChange, false];
36
- }
package/src/index.ts DELETED
@@ -1,2 +0,0 @@
1
- export * from "./hooks";
2
- export * from "./utils";
@@ -1,73 +0,0 @@
1
- type RGB = {
2
- r: number;
3
- g: number;
4
- b: number;
5
- };
6
-
7
- function parseHexColor(color: string): RGB | null {
8
- const value = color.trim().replace(/^#/, "");
9
-
10
- if (![3, 4, 6, 8].includes(value.length)) return null;
11
- if (!/^[0-9a-fA-F]+$/.test(value)) return null;
12
-
13
- const normalized =
14
- value.length === 3 || value.length === 4
15
- ? value
16
- .slice(0, 3)
17
- .split("")
18
- .map((char) => `${char}${char}`)
19
- .join("")
20
- : value.slice(0, 6);
21
-
22
- return {
23
- r: Number.parseInt(normalized.slice(0, 2), 16),
24
- g: Number.parseInt(normalized.slice(2, 4), 16),
25
- b: Number.parseInt(normalized.slice(4, 6), 16)
26
- };
27
- }
28
-
29
- function toRelativeLuminance(channel: number): number {
30
- const sRGB = channel / 255;
31
-
32
- if (sRGB <= 0.03928) return sRGB / 12.92;
33
-
34
- return ((sRGB + 0.055) / 1.055) ** 2.4;
35
- }
36
-
37
- function getLuminance(color: RGB): number {
38
- return (
39
- 0.2126 * toRelativeLuminance(color.r) +
40
- 0.7152 * toRelativeLuminance(color.g) +
41
- 0.0722 * toRelativeLuminance(color.b)
42
- );
43
- }
44
-
45
- function getContrastRatio(background: RGB, foreground: RGB): number {
46
- const backgroundLuminance = getLuminance(background);
47
- const foregroundLuminance = getLuminance(foreground);
48
-
49
- const lighter = Math.max(backgroundLuminance, foregroundLuminance);
50
- const darker = Math.min(backgroundLuminance, foregroundLuminance);
51
-
52
- return (lighter + 0.05) / (darker + 0.05);
53
- }
54
-
55
- export function getAutoContrastTextColor(
56
- background: string,
57
- darkText: string,
58
- lightText: string,
59
- fallbackColor: string
60
- ): string {
61
- const backgroundRGB = parseHexColor(background);
62
- if (!backgroundRGB) return fallbackColor;
63
-
64
- const darkTextRGB = parseHexColor(darkText);
65
- const lightTextRGB = parseHexColor(lightText);
66
-
67
- if (!darkTextRGB || !lightTextRGB) return fallbackColor;
68
-
69
- return getContrastRatio(backgroundRGB, darkTextRGB) >=
70
- getContrastRatio(backgroundRGB, lightTextRGB)
71
- ? darkText
72
- : lightText;
73
- }
@@ -1,13 +0,0 @@
1
- export function clamp(
2
- value: number,
3
- min: number | undefined,
4
- max: number | undefined
5
- ): number {
6
- if (min === undefined && max === undefined) return value;
7
-
8
- if (min !== undefined && max === undefined) return Math.max(min, value);
9
-
10
- if (min === undefined && max !== undefined) return Math.min(max, value);
11
-
12
- return Math.min(Math.max(value, min!), max!);
13
- }
@@ -1,2 +0,0 @@
1
- export { createOptionalContext } from "./optional-context";
2
- export { createSafeContext } from "./safe-context";
@@ -1,21 +0,0 @@
1
- import { createContext, useContext } from "react";
2
-
3
- export function createOptionalContext<ContextValue>(
4
- initialValue: ContextValue | null = null
5
- ) {
6
- const Context = createContext<ContextValue | null>(initialValue);
7
-
8
- const useOptionalContext = () => useContext(Context);
9
-
10
- const Provider = ({
11
- value,
12
- children
13
- }: {
14
- value: ContextValue;
15
- children: React.ReactNode;
16
- }) => {
17
- return <Context.Provider value={value}>{children}</Context.Provider>;
18
- };
19
-
20
- return [Provider, useOptionalContext] as const;
21
- }
@@ -1,25 +0,0 @@
1
- import { createContext, useContext } from "react";
2
-
3
- export function createSafeContext<ContextValue>(err: string) {
4
- const Context = createContext<ContextValue | null>(null);
5
-
6
- const useSafeContext = () => {
7
- const context = useContext(Context);
8
-
9
- if (context === null) {
10
- throw new Error(err);
11
- }
12
-
13
- return context;
14
- };
15
-
16
- const Provider = ({
17
- children,
18
- value
19
- }: {
20
- children: React.ReactNode;
21
- value: ContextValue;
22
- }) => <Context.Provider value={value}>{children}</Context.Provider>;
23
-
24
- return [Provider, useSafeContext] as const;
25
- }
@@ -1,22 +0,0 @@
1
- type Props = {
2
- value: number;
3
- min: number;
4
- max: number;
5
- step: number;
6
- precision?: number;
7
- };
8
-
9
- export function getChangeValue({
10
- value,
11
- min,
12
- max,
13
- step,
14
- precision
15
- }: Props): number {
16
- const scaled = min + value * (max - min);
17
- const stepped = Math.round(scaled / step) * step;
18
-
19
- if (precision !== undefined) return Number(stepped.toFixed(precision));
20
-
21
- return Math.min(Math.max(stepped, min), max);
22
- }
@@ -1,5 +0,0 @@
1
- export * from "./auto-contrast";
2
- export * from "./clamp";
3
- export * from "./context";
4
- export * from "./get-change-value";
5
- export * from "./storage";
@@ -1,203 +0,0 @@
1
- export interface StorageOptions<T> {
2
- /** Default value if key doesn't exist */
3
- defaultValue?: T;
4
-
5
- /** Time-to-live in milliseconds */
6
- ttl?: number;
7
-
8
- /** Custom serializer function */
9
- serializer?: (value: T) => string;
10
-
11
- /** Custom deserializer function */
12
- deserializer?: (value: string) => T;
13
- }
14
-
15
- export interface StorageItem<T> {
16
- value: T;
17
- expiry?: number;
18
- }
19
-
20
- export interface StorageEventPayload<T> {
21
- key: string;
22
- oldValue: T | null;
23
- newValue: T | null;
24
- }
25
-
26
- type StorageListener<T> = (payload: StorageEventPayload<T>) => void;
27
-
28
- const isBrowser = typeof window !== "undefined";
29
-
30
- export function createStorage<T>(key: string, options: StorageOptions<T> = {}) {
31
- const {
32
- defaultValue,
33
- ttl,
34
- serializer = JSON.stringify,
35
- deserializer = JSON.parse
36
- } = options;
37
-
38
- const listeners = new Set<StorageListener<T>>();
39
-
40
- const getFullKey = () => key;
41
-
42
- const get = (): T | null => {
43
- if (!isBrowser) return defaultValue ?? null;
44
-
45
- try {
46
- const raw = localStorage.getItem(getFullKey());
47
- if (raw === null) return defaultValue ?? null;
48
-
49
- const item: StorageItem<T> = deserializer(raw);
50
-
51
- if (item.expiry && Date.now() > item.expiry) {
52
- remove();
53
- return defaultValue ?? null;
54
- }
55
-
56
- return item.value;
57
- } catch {
58
- return defaultValue ?? null;
59
- }
60
- };
61
-
62
- const set = (value: T): void => {
63
- if (!isBrowser) return;
64
-
65
- try {
66
- const oldValue = get();
67
-
68
- const item: StorageItem<T> = {
69
- value,
70
- expiry: ttl ? Date.now() + ttl : undefined
71
- };
72
-
73
- localStorage.setItem(getFullKey(), serializer(item));
74
-
75
- listeners.forEach((listener) =>
76
- listener({ key: getFullKey(), oldValue, newValue: value })
77
- );
78
- } catch (error) {
79
- console.warn(`Failed to set localStorage key "${key}":`, error);
80
- }
81
- };
82
-
83
- const remove = (): void => {
84
- if (!isBrowser) return;
85
-
86
- const oldValue = get();
87
- localStorage.removeItem(getFullKey());
88
-
89
- listeners.forEach((listener) =>
90
- listener({ key: getFullKey(), oldValue, newValue: null })
91
- );
92
- };
93
-
94
- const exists = (): boolean => {
95
- if (!isBrowser) return false;
96
- return localStorage.getItem(getFullKey()) !== null;
97
- };
98
-
99
- const subscribe = (listener: StorageListener<T>): (() => void) => {
100
- listeners.add(listener);
101
-
102
- const handleStorageEvent = (event: StorageEvent) => {
103
- if (event.key === getFullKey()) {
104
- try {
105
- const oldValue = event.oldValue
106
- ? (deserializer(event.oldValue) as StorageItem<T>).value
107
- : null;
108
- const newValue = event.newValue
109
- ? (deserializer(event.newValue) as StorageItem<T>).value
110
- : null;
111
- listener({ key: getFullKey(), oldValue, newValue });
112
- } catch {
113
- // Ignore parse errors
114
- }
115
- }
116
- };
117
-
118
- if (isBrowser) {
119
- window.addEventListener("storage", handleStorageEvent);
120
- }
121
-
122
- return () => {
123
- listeners.delete(listener);
124
- if (isBrowser) {
125
- window.removeEventListener("storage", handleStorageEvent);
126
- }
127
- };
128
- };
129
-
130
- const update = (updater: (current: T | null) => T): void => {
131
- const current = get();
132
- set(updater(current));
133
- };
134
-
135
- return {
136
- get,
137
- set,
138
- remove,
139
- exists,
140
- subscribe,
141
- update,
142
- key: getFullKey()
143
- };
144
- }
145
-
146
- export const storage = {
147
- get<T>(key: string, defaultValue?: T): T | null {
148
- if (!isBrowser) return defaultValue ?? null;
149
-
150
- try {
151
- const raw = localStorage.getItem(key);
152
- if (raw === null) return defaultValue ?? null;
153
-
154
- const parsed = JSON.parse(raw);
155
-
156
- if (parsed && typeof parsed === "object" && "value" in parsed) {
157
- if (parsed.expiry && Date.now() > parsed.expiry) {
158
- localStorage.removeItem(key);
159
- return defaultValue ?? null;
160
- }
161
- return parsed.value;
162
- }
163
-
164
- return parsed;
165
- } catch {
166
- return defaultValue ?? null;
167
- }
168
- },
169
-
170
- set<T>(key: string, value: T, ttl?: number): void {
171
- if (!isBrowser) return;
172
-
173
- try {
174
- const item: StorageItem<T> = {
175
- value,
176
- expiry: ttl ? Date.now() + ttl : undefined
177
- };
178
- localStorage.setItem(key, JSON.stringify(item));
179
- } catch (error) {
180
- console.warn(`Failed to set localStorage key "${key}":`, error);
181
- }
182
- },
183
-
184
- remove(key: string): void {
185
- if (!isBrowser) return;
186
- localStorage.removeItem(key);
187
- },
188
-
189
- clear(): void {
190
- if (!isBrowser) return;
191
- localStorage.clear();
192
- },
193
-
194
- keys(): string[] {
195
- if (!isBrowser) return [];
196
- return Object.keys(localStorage);
197
- },
198
-
199
- size(): number {
200
- if (!isBrowser) return 0;
201
- return localStorage.length;
202
- }
203
- };
package/tsconfig.json DELETED
@@ -1,13 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.base.json",
3
- "compilerOptions": {
4
- "outDir": "./build",
5
- "rootDir": "./src",
6
- "declaration": true,
7
- "declarationMap": true,
8
- "noEmit": false,
9
- "allowImportingTsExtensions": false
10
- },
11
- "include": ["src/**/*"],
12
- "exclude": ["node_modules", "build"]
13
- }
@@ -1 +0,0 @@
1
- {"root":["./src/index.ts","./src/hooks/index.ts","./src/hooks/use-click-outside/index.ts","./src/hooks/use-debounced-callback/index.ts","./src/hooks/use-disclosure/index.ts","./src/hooks/use-id/index.ts","./src/hooks/use-isomorphic-layout-effect/index.ts","./src/hooks/use-merged-refs/index.ts","./src/hooks/use-resize-observer/index.ts","./src/hooks/use-uncontrolled/index.ts","./src/utils/index.ts","./src/utils/auto-contrast/index.ts","./src/utils/clamp/index.ts","./src/utils/context/index.ts","./src/utils/context/optional-context.tsx","./src/utils/context/safe-context.tsx","./src/utils/get-change-value/index.ts","./src/utils/storage/index.ts"],"version":"5.9.3"}