@djangocfg/ui-core 2.1.541 → 2.1.542

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.
Files changed (48) hide show
  1. package/README.md +3 -1
  2. package/package.json +12 -9
  3. package/src/components/data/BalancedText/hooks/useMaxLinesWidth.ts +4 -25
  4. package/src/components/forms/button-download/index.tsx +1 -1
  5. package/src/components/forms/datetime-field/date-time-field.tsx +1 -1
  6. package/src/components/forms/editable/index.tsx +7 -3
  7. package/src/components/forms/input-group/index.tsx +12 -0
  8. package/src/components/forms/mask-input/index.tsx +7 -3
  9. package/src/components/forms/money-field/README.md +79 -0
  10. package/src/components/forms/money-field/index.tsx +288 -0
  11. package/src/components/forms/otp/use-otp-input.ts +1 -1
  12. package/src/components/forms/tags-input/index.tsx +55 -41
  13. package/src/components/forms/time-picker/index.tsx +7 -3
  14. package/src/components/index.ts +4 -0
  15. package/src/components/layout/key-value/index.tsx +9 -7
  16. package/src/components/layout/resizable/index.tsx +6 -1
  17. package/src/components/navigation/command/index.tsx +24 -6
  18. package/src/components/navigation/link/LinkContext.tsx +3 -1
  19. package/src/components/navigation/pagination/pagination-static.tsx +1 -1
  20. package/src/components/navigation/tabs/index.tsx +30 -9
  21. package/src/components/overlay/responsive-sheet/index.tsx +4 -4
  22. package/src/components/select/helpers.tsx +1 -1
  23. package/src/components/select/multi-select-pro-async.tsx +13 -6
  24. package/src/components/select/multi-select-pro.tsx +3 -4
  25. package/src/components/specialized/flag/Flag.tsx +11 -5
  26. package/src/components/specialized/flag/flag-map.ts +13 -6
  27. package/src/components/specialized/image-with-fallback/index.tsx +9 -4
  28. package/src/components/specialized/presence/index.tsx +2 -3
  29. package/src/components/specialized/token-icon/index.tsx +26 -13
  30. package/src/hooks/audio/useAudioPrefs.ts +8 -3
  31. package/src/hooks/device/useBrowserDetect.ts +5 -1
  32. package/src/hooks/dom/useImageLoader.ts +24 -20
  33. package/src/hooks/dom/useScroll.ts +7 -6
  34. package/src/hooks/events/useEventsBus.ts +19 -5
  35. package/src/hooks/hotkey/useHotkeyChord.ts +11 -4
  36. package/src/hooks/hotkey/useHotkeyHelp.ts +8 -3
  37. package/src/hooks/router/adapter.tsx +3 -1
  38. package/src/hooks/state/storage-quota.ts +27 -0
  39. package/src/hooks/state/useDebouncedCallback.ts +26 -20
  40. package/src/hooks/state/useLocalStorage.ts +7 -13
  41. package/src/hooks/state/useSessionStorage.ts +7 -9
  42. package/src/lib/compose-event-handlers.ts +5 -5
  43. package/src/lib/dialog-service/getDialog.ts +1 -1
  44. package/src/lib/get-element-ref.ts +9 -6
  45. package/src/lib/pretext/pretext.types.ts +25 -70
  46. package/src/lib/pretext/use-pretext.ts +8 -12
  47. package/src/snippets/LazyComponent.tsx +9 -9
  48. package/src/styles/palette/useThemePalette.ts +7 -0
@@ -12,11 +12,18 @@ import type * as React from 'react';
12
12
  import * as Flags from 'country-flag-icons/react/3x2';
13
13
 
14
14
  /**
15
- * `country-flag-icons` types its components against `HTMLSVGElement`
16
- * (their custom intersection of HTMLElement + SVGElement). We treat them
17
- * as opaque components — call sites use `<Flag />` rather than touching
18
- * the DOM type directly.
15
+ * `country-flag-icons` declares its components against `HTMLSVGElement` — its
16
+ * own `HTMLElement & SVGElement` intersection, which no DOM node satisfies. Its
17
+ * event-handler props are consequently uncallable with a real SVG event, so
18
+ * `FlagProps` exposes only the presentational attributes `Flag` forwards; a
19
+ * handler would have to be typed against an element that cannot exist.
19
20
  */
20
- export type FlagSvgComponent = (props: React.SVGProps<SVGSVGElement> & React.HTMLAttributes<SVGSVGElement>) => React.JSX.Element;
21
+ export type FlagSvgComponent = (typeof Flags)[keyof typeof Flags];
21
22
 
22
- export const FLAG_COMPONENTS = Flags as unknown as Record<string, FlagSvgComponent>;
23
+ /**
24
+ * Keyed by country code rather than indexed off the namespace object, because
25
+ * the lookup code is a runtime string and a module namespace admits only its
26
+ * literal export names.
27
+ */
28
+ export const FLAG_COMPONENTS: Record<string, FlagSvgComponent> =
29
+ Object.fromEntries(Object.entries(Flags));
@@ -24,9 +24,12 @@ export interface ImageWithFallbackProps extends Omit<React.ImgHTMLAttributes<HTM
24
24
  placeholder?: string;
25
25
  blurDataURL?: string;
26
26
 
27
- // Callbacks
28
- onLoad?: (event: React.SyntheticEvent<HTMLImageElement>) => void;
29
- onError?: (event: React.SyntheticEvent<HTMLImageElement>) => void;
27
+ // Callbacks. Fired from two sources with different event shapes: the
28
+ // preloader (`useImageLoader`) passes a DOM `Event` from a detached
29
+ // `new Image()`, the rendered <img> passes a React synthetic event. Narrow
30
+ // before touching anything beyond `type`/`target`.
31
+ onLoad?: (event: Event | React.SyntheticEvent<HTMLImageElement>) => void;
32
+ onError?: (event: Event | React.SyntheticEvent<HTMLImageElement>) => void;
30
33
  onLoadStart?: () => void;
31
34
 
32
35
  // Performance
@@ -55,7 +58,9 @@ export const ImageWithFallback = forwardRef<HTMLImageElement, ImageWithFallbackP
55
58
 
56
59
  // Performance
57
60
  priority = false,
58
- unoptimized = false,
61
+ // Accepted for next/image API parity; a plain <img> has nothing to
62
+ // optimize. Destructured so it never reaches the DOM as an attribute.
63
+ unoptimized: _unoptimized = false,
59
64
 
60
65
  // Standard img attributes
61
66
  width,
@@ -34,9 +34,8 @@ Presence.displayName = "Presence";
34
34
 
35
35
  function usePresence(present: boolean) {
36
36
  const [node, setNode] = React.useState<HTMLElement>();
37
- const stylesRef = React.useRef<CSSStyleDeclaration>(
38
- {} as unknown as CSSStyleDeclaration,
39
- );
37
+ // Undefined until the node mounts; every read below already tolerates that.
38
+ const stylesRef = React.useRef<CSSStyleDeclaration | undefined>(undefined);
40
39
  const prevPresentRef = React.useRef(present);
41
40
  const prevAnimationNameRef = React.useRef<string>("none");
42
41
  const initialState = present ? "mounted" : "unmounted";
@@ -80,25 +80,36 @@ export async function getTokensByCategory(): Promise<TokenCategory[]> {
80
80
 
81
81
  export type TokenSymbol = string
82
82
 
83
+ /** The only props this file passes to a resolved @web3icons component. */
84
+ type TokenIconComponent = React.ComponentType<{
85
+ variant?: 'branded' | 'mono'
86
+ size?: number
87
+ }>
88
+
83
89
  // Dynamic import for @web3icons/react tokens
84
- const tokenIconCache = new Map<string, React.ComponentType<any>>()
90
+ const tokenIconCache = new Map<string, TokenIconComponent>()
85
91
 
86
- async function getTokenIcon(symbol: string): Promise<React.ComponentType<any> | null> {
92
+ async function getTokenIcon(symbol: string): Promise<TokenIconComponent | null> {
87
93
  const normalizedSymbol = symbol.toUpperCase().replace(/[^A-Z0-9]/g, '_')
88
94
  const componentName = `Token${normalizedSymbol}`
89
95
 
90
- if (tokenIconCache.has(componentName)) {
91
- return tokenIconCache.get(componentName)!
96
+ const cached = tokenIconCache.get(componentName)
97
+ if (cached) {
98
+ return cached
92
99
  }
93
100
 
94
101
  try {
95
- const module = await import('@web3icons/react')
96
- const Component = (module as any)[componentName]
97
- if (Component) {
98
- tokenIconCache.set(componentName, Component)
99
- return Component
102
+ // The export name is computed at runtime, so the module is read as an
103
+ // untyped key map and the value is proven to be a component (a function)
104
+ // before it is used.
105
+ const namespace: Record<string, unknown> = await import('@web3icons/react')
106
+ const Component = namespace[componentName]
107
+ if (typeof Component === 'function') {
108
+ const IconComponent = Component as TokenIconComponent
109
+ tokenIconCache.set(componentName, IconComponent)
110
+ return IconComponent
100
111
  }
101
- } catch (e) {
112
+ } catch (_e) {
102
113
  // Token not found
103
114
  }
104
115
 
@@ -110,11 +121,13 @@ export function TokenIcon({
110
121
  variant = 'branded',
111
122
  size = 24,
112
123
  className,
113
- network,
114
- address,
124
+ // Destructured, not used: these narrow which icon is resolved upstream
125
+ // and must not fall through `...props` onto the DOM element below.
126
+ network: _network,
127
+ address: _address,
115
128
  ...props
116
129
  }: TokenIconProps) {
117
- const [IconComponent, setIconComponent] = React.useState<React.ComponentType<any> | null>(null)
130
+ const [IconComponent, setIconComponent] = React.useState<TokenIconComponent | null>(null)
118
131
  const [_loading, setLoading] = React.useState(true)
119
132
 
120
133
  React.useEffect(() => {
@@ -32,6 +32,11 @@ const clamp01 = (v: number): number => {
32
32
  * Per-key registry so repeated calls with the same `storageKey` return
33
33
  * the same store instance — necessary for cross-component state sharing
34
34
  * (e.g. ChatHeader audio toggle + ChatLauncher bus reading the same key).
35
+ *
36
+ * `E` is phantom — nothing at runtime restricts which event names the store
37
+ * accepts, so one instance serves callers that name different event unions.
38
+ * The store is therefore built and stored at the widest instantiation, and
39
+ * every narrower `AudioPrefsState<E>` is assignable from it.
35
40
  */
36
41
  const registry = new Map<string, UseBoundStore<StoreApi<AudioPrefsState<string>>>>();
37
42
 
@@ -39,9 +44,9 @@ export function createAudioPrefsStore<E extends string = string>(
39
44
  storageKey: string,
40
45
  ): UseBoundStore<StoreApi<AudioPrefsState<E>>> {
41
46
  const cached = registry.get(storageKey);
42
- if (cached) return cached as unknown as UseBoundStore<StoreApi<AudioPrefsState<E>>>;
47
+ if (cached) return cached;
43
48
 
44
- const store = create<AudioPrefsState<E>>()(
49
+ const store = create<AudioPrefsState<string>>()(
45
50
  persist(
46
51
  (set) => ({
47
52
  volume: 1,
@@ -70,7 +75,7 @@ export function createAudioPrefsStore<E extends string = string>(
70
75
  ),
71
76
  );
72
77
 
73
- registry.set(storageKey, store as unknown as UseBoundStore<StoreApi<AudioPrefsState<string>>>);
78
+ registry.set(storageKey, store);
74
79
  return store;
75
80
  }
76
81
 
@@ -139,7 +139,11 @@ export function useBrowserDetect(): BrowserInfo {
139
139
  const isEdge = ua.includes('edg/') || ua.includes('edge/');
140
140
 
141
141
  // Brave (check for Brave-specific API)
142
- const isBrave = !!(window.navigator as any).brave;
142
+ // Boundary: `navigator.brave` is a Brave-only extension absent from lib.dom.
143
+ // Declaring the one optional field we read keeps the probe honest without
144
+ // asserting anything about the rest of Navigator.
145
+ const braveAware: Navigator & { brave?: unknown } = window.navigator;
146
+ const isBrave = braveAware.brave != null;
143
147
 
144
148
  // Arc (check for Arc-specific markers in UA)
145
149
  const isArc = ua.includes('arc/');
@@ -6,7 +6,7 @@
6
6
 
7
7
  'use client';
8
8
 
9
- import { useCallback, useEffect, useState } from 'react';
9
+ import { useCallback, useEffect, useRef, useState } from 'react';
10
10
 
11
11
  export interface ImageLoaderState {
12
12
  isLoading: boolean;
@@ -16,8 +16,13 @@ export interface ImageLoaderState {
16
16
 
17
17
  export interface ImageLoaderCallbacks {
18
18
  onLoadStart?: () => void;
19
- onLoad?: (event: React.SyntheticEvent<HTMLImageElement>) => void;
20
- onError?: (event: React.SyntheticEvent<HTMLImageElement>) => void;
19
+ /**
20
+ * Receives the DOM `Event` from a detached `new Image()`, NOT a React
21
+ * synthetic event: there is no React element behind this load, so
22
+ * `persist`, `nativeEvent` and `isDefaultPrevented` do not exist on it.
23
+ */
24
+ onLoad?: (event: Event) => void;
25
+ onError?: (event: Event) => void;
21
26
  }
22
27
 
23
28
  export const useImageLoader = (
@@ -30,18 +35,23 @@ export const useImageLoader = (
30
35
  hasError: false,
31
36
  });
32
37
 
38
+ // Callbacks are read through a ref so they never participate in the deps
39
+ // below. Callers pass `callbacks` as an inline object literal, so depending on
40
+ // it would re-run the load effect on every render — refetching the image and
41
+ // re-firing onLoadStart indefinitely. The ref keeps the latest handlers
42
+ // without tying image loading to render identity.
43
+ const callbacksRef = useRef(callbacks);
44
+ callbacksRef.current = callbacks;
45
+
33
46
  const handleLoad = useCallback((event: Event) => {
34
47
  setState({
35
48
  isLoading: false,
36
49
  isLoaded: true,
37
50
  hasError: false,
38
51
  });
39
-
40
- // Call user callback if provided
41
- if (callbacks?.onLoad) {
42
- callbacks.onLoad(event as unknown as React.SyntheticEvent<HTMLImageElement>);
43
- }
44
- }, [callbacks?.onLoad]);
52
+
53
+ callbacksRef.current?.onLoad?.(event);
54
+ }, []);
45
55
 
46
56
  const handleError = useCallback((event: Event) => {
47
57
  setState({
@@ -49,12 +59,9 @@ export const useImageLoader = (
49
59
  isLoaded: false,
50
60
  hasError: true,
51
61
  });
52
-
53
- // Call user callback if provided
54
- if (callbacks?.onError) {
55
- callbacks.onError(event as unknown as React.SyntheticEvent<HTMLImageElement>);
56
- }
57
- }, [callbacks?.onError]);
62
+
63
+ callbacksRef.current?.onError?.(event);
64
+ }, []);
58
65
 
59
66
  useEffect(() => {
60
67
  if (!src) {
@@ -72,10 +79,7 @@ export const useImageLoader = (
72
79
  hasError: false,
73
80
  });
74
81
 
75
- // Call load start callback
76
- if (callbacks?.onLoadStart) {
77
- callbacks.onLoadStart();
78
- }
82
+ callbacksRef.current?.onLoadStart?.();
79
83
 
80
84
  const img = new Image();
81
85
 
@@ -89,7 +93,7 @@ export const useImageLoader = (
89
93
  img.removeEventListener('load', handleLoad);
90
94
  img.removeEventListener('error', handleError);
91
95
  };
92
- }, [src, handleLoad, handleError, callbacks?.onLoadStart]);
96
+ }, [src, handleLoad, handleError]);
93
97
 
94
98
  return state;
95
99
  };
@@ -47,7 +47,7 @@
47
47
  * const direction = useScrollDirection();
48
48
  */
49
49
 
50
- import { useCallback, useSyncExternalStore, type RefObject } from 'react';
50
+ import { useCallback, useMemo, useSyncExternalStore, type RefObject } from 'react';
51
51
 
52
52
  // ─────────────────────────────────────────────────────────────────────────────
53
53
  // Types
@@ -264,8 +264,9 @@ const getServerSnapshot = (): ScrollSnapshot => SSR_SNAPSHOT;
264
264
  * underlying store but let React skip re-renders when other fields change.
265
265
  */
266
266
  export function useScroll(target?: ScrollTarget): ScrollSnapshot {
267
- const subscribe = useCallback(subscribeFactory(target), [target]);
268
- const getSnapshot = useCallback(getSnapshotFactory(target), [target]);
267
+ // useMemo, not useCallback: these are factory RESULTS, not inline functions.
268
+ const subscribe = useMemo(() => subscribeFactory(target), [target]);
269
+ const getSnapshot = useMemo(() => getSnapshotFactory(target), [target]);
269
270
  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
270
271
  }
271
272
 
@@ -274,7 +275,7 @@ export function useScroll(target?: ScrollTarget): ScrollSnapshot {
274
275
  * change — direction flips and isScrolling transitions are ignored.
275
276
  */
276
277
  export function useScrollPosition(target?: ScrollTarget): { x: number; y: number } {
277
- const subscribe = useCallback(subscribeFactory(target), [target]);
278
+ const subscribe = useMemo(() => subscribeFactory(target), [target]);
278
279
  const getServerXY = useCallback(() => SSR_XY, []);
279
280
  const getXY = useCallback(() => {
280
281
  const snap = getSnapshotFactory(target)();
@@ -298,7 +299,7 @@ function cachePosition(snap: ScrollSnapshot): { x: number; y: number } {
298
299
  * flips (e.g. user reverses scroll).
299
300
  */
300
301
  export function useScrollDirection(target?: ScrollTarget): ScrollDirection {
301
- const subscribe = useCallback(subscribeFactory(target), [target]);
302
+ const subscribe = useMemo(() => subscribeFactory(target), [target]);
302
303
  const getDirection = useCallback(
303
304
  () => getSnapshotFactory(target)().direction,
304
305
  [target]
@@ -312,7 +313,7 @@ export function useScrollDirection(target?: ScrollTarget): ScrollDirection {
312
313
  * idle gap. Useful for hiding hover overlays while scrolling.
313
314
  */
314
315
  export function useIsScrolling(target?: ScrollTarget): boolean {
315
- const subscribe = useCallback(subscribeFactory(target), [target]);
316
+ const subscribe = useMemo(() => subscribeFactory(target), [target]);
316
317
  const getIsScrolling = useCallback(
317
318
  () => getSnapshotFactory(target)().isScrolling,
318
319
  [target]
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useEffect, useRef } from 'react';
4
4
 
5
- export type FormEvent<T extends string = string, P = any> = {
5
+ export type FormEvent<T extends string = string, P = unknown> = {
6
6
  type: T;
7
7
  payload?: P;
8
8
  timestamp?: number;
@@ -11,7 +11,9 @@ export type FormEvent<T extends string = string, P = any> = {
11
11
  type EventListener<T extends FormEvent> = (event: T) => void;
12
12
 
13
13
  class EventBus {
14
- private listeners: Set<EventListener<any>> = new Set();
14
+ // Subscribers each declare their own event type; the bus only ever hands
15
+ // them a FormEvent, so it stores the widest listener it can actually call.
16
+ private listeners: Set<EventListener<FormEvent>> = new Set();
15
17
 
16
18
  publish<T extends FormEvent>(event: T) {
17
19
  this.listeners.forEach(listener => listener({
@@ -20,7 +22,10 @@ class EventBus {
20
22
  }));
21
23
  }
22
24
 
23
- subscribe<T extends FormEvent>(listener: EventListener<T>) {
25
+ // Not generic on purpose: the bus broadcasts EVERY event to EVERY listener,
26
+ // so a listener declaring a narrower event type would be handed events that
27
+ // do not match it. Filtering by `type` is the subscriber's job.
28
+ subscribe(listener: EventListener<FormEvent>) {
24
29
  this.listeners.add(listener);
25
30
  return () => {
26
31
  this.listeners.delete(listener);
@@ -30,9 +35,18 @@ class EventBus {
30
35
 
31
36
  export const events = new EventBus();
32
37
 
33
- export function useEventListener<T extends string, P>(
38
+ /**
39
+ * Subscribe to one event type on the bus.
40
+ *
41
+ * The payload arrives from an untyped publisher, so it is delivered as
42
+ * `unknown`: this is the boundary, and the handler narrows it. Callers that
43
+ * know the publisher's shape should narrow with a type guard rather than
44
+ * declaring the shape and hoping — nothing at runtime enforces the pairing of
45
+ * `eventType` and payload.
46
+ */
47
+ export function useEventListener<T extends string>(
34
48
  eventType: T,
35
- handler: (payload: P) => void
49
+ handler: (payload: unknown) => void
36
50
  ) {
37
51
  const savedHandler = useRef(handler);
38
52
 
@@ -48,12 +48,19 @@ export function useHotkeyChord(
48
48
  const callbackRef = useRef(callback);
49
49
  callbackRef.current = callback;
50
50
 
51
+ // The listener is rebound on the chord's CONTENTS, not the array's identity:
52
+ // callers write `useHotkeyChord(['g', 't'], …)` with a fresh literal each
53
+ // render, which would otherwise tear down and re-add the keydown listener
54
+ // continuously and reset any chord in progress.
55
+ const keysKey = keys.join('|');
56
+ const keysRef = useRef(keys);
57
+ keysRef.current = keys;
58
+
51
59
  useEffect(() => {
52
60
  if (!enabled) return;
53
61
  if (typeof window === 'undefined') return;
54
- if (!keys.length) return;
55
-
56
- const sequence = keys.map((k) => k.toLowerCase());
62
+ const sequence = keysRef.current.map((k) => k.toLowerCase());
63
+ if (!sequence.length) return;
57
64
  let progress = 0;
58
65
  let timer: ReturnType<typeof setTimeout> | null = null;
59
66
 
@@ -92,5 +99,5 @@ export function useHotkeyChord(
92
99
  window.removeEventListener('keydown', onKey);
93
100
  if (timer) clearTimeout(timer);
94
101
  };
95
- }, [keys.join('|'), enabled, chordWindow, enableOnFormTags, preventDefault]);
102
+ }, [keysKey, enabled, chordWindow, enableOnFormTags, preventDefault]);
96
103
  }
@@ -61,8 +61,13 @@ export function getRegisteredHotkeys(): RegisteredHotkey[] {
61
61
  * still want to appear in the cheat sheet).
62
62
  */
63
63
  export function useRegisterHotkey(entry: RegisteredHotkey | null): void {
64
+ // Registration is keyed on the entry's FIELDS, not its identity: callers pass
65
+ // an inline object, so depending on `entry` would unregister and re-register
66
+ // on every render and notify every cheat-sheet subscriber each time.
67
+ const { combo, description, scope } = entry ?? {};
68
+
64
69
  useEffect(() => {
65
- if (!entry) return;
66
- return registerHotkey(entry);
67
- }, [entry?.combo, entry?.description, entry?.scope]);
70
+ if (!combo || !description) return;
71
+ return registerHotkey({ combo, description, scope });
72
+ }, [combo, description, scope]);
68
73
  }
@@ -113,7 +113,9 @@ export const defaultAdapter: RouterAdapter = Object.freeze({
113
113
  // every hook reads another — falling back to the History API, which changes the
114
114
  // URL without telling the host router.
115
115
  const CONTEXT_KEY = Symbol.for('@djangocfg/ui-core.RouterAdapterContext');
116
- const globalScope = globalThis as unknown as Record<symbol, unknown>;
116
+ // `typeof globalThis` carries no index signature, but the object genuinely
117
+ // accepts symbol keys — this asserts a property the runtime has.
118
+ const globalScope = globalThis as Record<symbol, unknown>;
117
119
 
118
120
  export const RouterAdapterContext =
119
121
  (globalScope[CONTEXT_KEY] as ReturnType<typeof createContext<RouterAdapter | null>> | undefined) ??
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Shared quota detection for the localStorage / sessionStorage hooks.
3
+ *
4
+ * Internal — not exported from the package entry point.
5
+ */
6
+
7
+ /**
8
+ * Is this thrown value the browser's "storage is full" signal?
9
+ *
10
+ * Boundary: a `catch` binding is `unknown`, and browsers disagree on how they
11
+ * report a full quota — Chrome/Safari throw `QuotaExceededError`, older engines
12
+ * use the legacy DOMException code 22, and some report it only in the message.
13
+ * Each field is therefore probed rather than assumed to exist.
14
+ */
15
+ export function isQuotaExceededError(error: unknown): boolean {
16
+ if (typeof error !== 'object' || error === null) return false;
17
+
18
+ const { name, code, message } = error as {
19
+ name?: unknown;
20
+ code?: unknown;
21
+ message?: unknown;
22
+ };
23
+
24
+ if (name === 'QuotaExceededError') return true;
25
+ if (code === 22) return true;
26
+ return typeof message === 'string' && message.includes('quota');
27
+ }
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useCallback, useEffect, useRef } from 'react';
3
+ import { useCallback, useEffect, useMemo, useRef } from 'react';
4
4
 
5
5
  /**
6
6
  * Creates a debounced version of a callback function.
@@ -9,10 +9,15 @@ import { useCallback, useEffect, useRef } from 'react';
9
9
  * @param delay The debounce delay in milliseconds.
10
10
  * @returns A debounced callback function.
11
11
  */
12
- export function useDebouncedCallback<T extends (...args: any[]) => any>(
12
+ /** The debounced function, plus the `cancel` handle this hook attaches to it. */
13
+ export type DebouncedCallback<T extends (...args: never[]) => unknown> = ((
14
+ ...args: Parameters<T>
15
+ ) => void) & { cancel: () => void };
16
+
17
+ export function useDebouncedCallback<T extends (...args: never[]) => unknown>(
13
18
  callback: T,
14
19
  delay: number
15
- ): (...args: Parameters<T>) => void {
20
+ ): DebouncedCallback<T> {
16
21
  const callbackRef = useRef(callback);
17
22
  const timeoutRef = useRef<NodeJS.Timeout | null>(null);
18
23
 
@@ -30,29 +35,30 @@ export function useDebouncedCallback<T extends (...args: any[]) => any>(
30
35
  };
31
36
  }, []);
32
37
 
33
- const debouncedCallback = useCallback(
34
- (...args: Parameters<T>) => {
35
- if (timeoutRef.current) {
36
- clearTimeout(timeoutRef.current);
37
- }
38
-
39
- timeoutRef.current = setTimeout(() => {
40
- callbackRef.current(...args);
41
- }, delay);
42
- },
43
- [delay]
44
- );
45
-
46
- // Add a cancel method to the debounced function
47
- // We attach it directly to the function object
48
- (debouncedCallback as any).cancel = useCallback(() => {
38
+ const cancel = useCallback(() => {
49
39
  if (timeoutRef.current) {
50
40
  clearTimeout(timeoutRef.current);
51
41
  timeoutRef.current = null;
52
42
  }
53
43
  }, []);
54
44
 
55
- return debouncedCallback;
45
+ // `cancel` is part of the returned type, so it is assembled with the
46
+ // function rather than bolted on afterwards.
47
+ return useMemo(() => {
48
+ const debounced: DebouncedCallback<T> = Object.assign(
49
+ (...args: Parameters<T>) => {
50
+ if (timeoutRef.current) {
51
+ clearTimeout(timeoutRef.current);
52
+ }
53
+
54
+ timeoutRef.current = setTimeout(() => {
55
+ callbackRef.current(...args);
56
+ }, delay);
57
+ },
58
+ { cancel }
59
+ );
60
+ return debounced;
61
+ }, [delay, cancel]);
56
62
  }
57
63
 
58
64
  export default useDebouncedCallback;
@@ -2,6 +2,8 @@
2
2
 
3
3
  import { useCallback, useEffect, useRef, useState } from 'react';
4
4
 
5
+ import { isQuotaExceededError } from './storage-quota';
6
+
5
7
  /**
6
8
  * Storage wrapper format with metadata
7
9
  * Used when TTL is specified
@@ -254,7 +256,7 @@ export function useLocalStorage<T>(
254
256
  }, [key, parseRaw]);
255
257
 
256
258
  // Check data size and limit
257
- const checkDataSize = (data: any): boolean => {
259
+ const checkDataSize = (data: unknown): boolean => {
258
260
  try {
259
261
  const jsonString = JSON.stringify(data);
260
262
  const sizeInBytes = new Blob([jsonString]).size;
@@ -338,12 +340,8 @@ export function useLocalStorage<T>(
338
340
  (dataToStore: string): void => {
339
341
  try {
340
342
  window.localStorage.setItem(key, dataToStore);
341
- } catch (storageError: any) {
342
- if (
343
- storageError.name === 'QuotaExceededError' ||
344
- storageError.code === 22 ||
345
- storageError.message?.includes('quota')
346
- ) {
343
+ } catch (storageError) {
344
+ if (isQuotaExceededError(storageError)) {
347
345
  console.warn('localStorage quota exceeded, clearing old data...');
348
346
  clearOldData();
349
347
  try {
@@ -481,12 +479,8 @@ export function useLocalStorage<T>(
481
479
  if (typeof window !== 'undefined') {
482
480
  try {
483
481
  window.localStorage.removeItem(key);
484
- } catch (removeError: any) {
485
- if (
486
- removeError.name === 'QuotaExceededError' ||
487
- removeError.code === 22 ||
488
- removeError.message?.includes('quota')
489
- ) {
482
+ } catch (removeError) {
483
+ if (isQuotaExceededError(removeError)) {
490
484
  console.warn('localStorage quota exceeded during removal, clearing old data...');
491
485
  clearOldData();
492
486
  try {
@@ -2,6 +2,8 @@
2
2
 
3
3
  import { useState } from 'react';
4
4
 
5
+ import { isQuotaExceededError } from './storage-quota';
6
+
5
7
  /**
6
8
  * Storage wrapper format with metadata
7
9
  * Used when TTL is specified
@@ -131,7 +133,7 @@ export function useSessionStorage<T>(
131
133
  });
132
134
 
133
135
  // Check data size and limit
134
- const checkDataSize = (data: any): boolean => {
136
+ const checkDataSize = (data: unknown): boolean => {
135
137
  try {
136
138
  const jsonString = JSON.stringify(data);
137
139
  const sizeInBytes = new Blob([jsonString]).size;
@@ -236,11 +238,9 @@ export function useSessionStorage<T>(
236
238
  // Try to set the value
237
239
  try {
238
240
  window.sessionStorage.setItem(key, dataToStore);
239
- } catch (storageError: any) {
241
+ } catch (storageError) {
240
242
  // If quota exceeded, clear old data and try again
241
- if (storageError.name === 'QuotaExceededError' ||
242
- storageError.code === 22 ||
243
- storageError.message?.includes('quota')) {
243
+ if (isQuotaExceededError(storageError)) {
244
244
  console.warn('sessionStorage quota exceeded, clearing old data...');
245
245
  clearOldData();
246
246
 
@@ -279,11 +279,9 @@ export function useSessionStorage<T>(
279
279
  if (typeof window !== 'undefined') {
280
280
  try {
281
281
  window.sessionStorage.removeItem(key);
282
- } catch (removeError: any) {
282
+ } catch (removeError) {
283
283
  // If removal fails due to quota, try to clear some data first
284
- if (removeError.name === 'QuotaExceededError' ||
285
- removeError.code === 22 ||
286
- removeError.message?.includes('quota')) {
284
+ if (isQuotaExceededError(removeError)) {
287
285
  console.warn('sessionStorage quota exceeded during removal, clearing old data...');
288
286
  clearOldData();
289
287
 
@@ -1,6 +1,9 @@
1
1
  "use client";
2
2
 
3
- function composeEventHandlers<E>(
3
+ // Constrained to values that actually carry `defaultPrevented` — both DOM
4
+ // events and React synthetic events qualify. Without it the guard below reads a
5
+ // property the type never promised.
6
+ function composeEventHandlers<E extends { defaultPrevented: boolean }>(
4
7
  originalEventHandler?: (event: E) => void,
5
8
  ourEventHandler?: (event: E) => void,
6
9
  { checkForDefaultPrevented = true } = {},
@@ -8,10 +11,7 @@ function composeEventHandlers<E>(
8
11
  return function handleEvent(event: E) {
9
12
  originalEventHandler?.(event);
10
13
 
11
- if (
12
- checkForDefaultPrevented &&
13
- (event as unknown as Event).defaultPrevented
14
- ) {
14
+ if (checkForDefaultPrevented && event.defaultPrevented) {
15
15
  return;
16
16
  }
17
17
 
@@ -30,7 +30,7 @@ export function getDialog(): DialogAPI | null {
30
30
  if (!api) {
31
31
  if (process.env.NODE_ENV !== 'production' && !warnedMissing) {
32
32
  warnedMissing = true;
33
- // eslint-disable-next-line no-console
33
+
34
34
  console.warn(
35
35
  '[getDialog] window.dialog is not available — mount <DialogProvider /> at the app root.',
36
36
  );