@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.
- package/README.md +3 -1
- package/package.json +12 -9
- package/src/components/data/BalancedText/hooks/useMaxLinesWidth.ts +4 -25
- package/src/components/forms/button-download/index.tsx +1 -1
- package/src/components/forms/datetime-field/date-time-field.tsx +1 -1
- package/src/components/forms/editable/index.tsx +7 -3
- package/src/components/forms/input-group/index.tsx +12 -0
- package/src/components/forms/mask-input/index.tsx +7 -3
- package/src/components/forms/money-field/README.md +79 -0
- package/src/components/forms/money-field/index.tsx +288 -0
- package/src/components/forms/otp/use-otp-input.ts +1 -1
- package/src/components/forms/tags-input/index.tsx +55 -41
- package/src/components/forms/time-picker/index.tsx +7 -3
- package/src/components/index.ts +4 -0
- package/src/components/layout/key-value/index.tsx +9 -7
- package/src/components/layout/resizable/index.tsx +6 -1
- package/src/components/navigation/command/index.tsx +24 -6
- package/src/components/navigation/link/LinkContext.tsx +3 -1
- package/src/components/navigation/pagination/pagination-static.tsx +1 -1
- package/src/components/navigation/tabs/index.tsx +30 -9
- package/src/components/overlay/responsive-sheet/index.tsx +4 -4
- package/src/components/select/helpers.tsx +1 -1
- package/src/components/select/multi-select-pro-async.tsx +13 -6
- package/src/components/select/multi-select-pro.tsx +3 -4
- package/src/components/specialized/flag/Flag.tsx +11 -5
- package/src/components/specialized/flag/flag-map.ts +13 -6
- package/src/components/specialized/image-with-fallback/index.tsx +9 -4
- package/src/components/specialized/presence/index.tsx +2 -3
- package/src/components/specialized/token-icon/index.tsx +26 -13
- package/src/hooks/audio/useAudioPrefs.ts +8 -3
- package/src/hooks/device/useBrowserDetect.ts +5 -1
- package/src/hooks/dom/useImageLoader.ts +24 -20
- package/src/hooks/dom/useScroll.ts +7 -6
- package/src/hooks/events/useEventsBus.ts +19 -5
- package/src/hooks/hotkey/useHotkeyChord.ts +11 -4
- package/src/hooks/hotkey/useHotkeyHelp.ts +8 -3
- package/src/hooks/router/adapter.tsx +3 -1
- package/src/hooks/state/storage-quota.ts +27 -0
- package/src/hooks/state/useDebouncedCallback.ts +26 -20
- package/src/hooks/state/useLocalStorage.ts +7 -13
- package/src/hooks/state/useSessionStorage.ts +7 -9
- package/src/lib/compose-event-handlers.ts +5 -5
- package/src/lib/dialog-service/getDialog.ts +1 -1
- package/src/lib/get-element-ref.ts +9 -6
- package/src/lib/pretext/pretext.types.ts +25 -70
- package/src/lib/pretext/use-pretext.ts +8 -12
- package/src/snippets/LazyComponent.tsx +9 -9
- 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`
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* the
|
|
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 = (
|
|
21
|
+
export type FlagSvgComponent = (typeof Flags)[keyof typeof Flags];
|
|
21
22
|
|
|
22
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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,
|
|
90
|
+
const tokenIconCache = new Map<string, TokenIconComponent>()
|
|
85
91
|
|
|
86
|
-
async function getTokenIcon(symbol: string): Promise<
|
|
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
|
-
|
|
91
|
-
|
|
96
|
+
const cached = tokenIconCache.get(componentName)
|
|
97
|
+
if (cached) {
|
|
98
|
+
return cached
|
|
92
99
|
}
|
|
93
100
|
|
|
94
101
|
try {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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 (
|
|
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
|
-
|
|
114
|
-
|
|
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<
|
|
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
|
|
47
|
+
if (cached) return cached;
|
|
43
48
|
|
|
44
|
-
const store = create<AudioPrefsState<
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
268
|
-
const
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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 (!
|
|
66
|
-
return registerHotkey(
|
|
67
|
-
}, [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
):
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
241
|
+
} catch (storageError) {
|
|
240
242
|
// If quota exceeded, clear old data and try again
|
|
241
|
-
if (storageError
|
|
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
|
|
282
|
+
} catch (removeError) {
|
|
283
283
|
// If removal fails due to quota, try to clear some data first
|
|
284
|
-
if (removeError
|
|
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
|
-
|
|
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
|
-
|
|
33
|
+
|
|
34
34
|
console.warn(
|
|
35
35
|
'[getDialog] window.dialog is not available — mount <DialogProvider /> at the app root.',
|
|
36
36
|
);
|