@djangocfg/ui-core 2.1.540 → 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 (50) 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/index.tsx +33 -6
  8. package/src/components/forms/input-group/index.tsx +32 -17
  9. package/src/components/forms/mask-input/index.tsx +7 -3
  10. package/src/components/forms/money-field/README.md +79 -0
  11. package/src/components/forms/money-field/index.tsx +288 -0
  12. package/src/components/forms/otp/use-otp-input.ts +1 -1
  13. package/src/components/forms/tags-input/index.tsx +55 -41
  14. package/src/components/forms/textarea/index.tsx +10 -4
  15. package/src/components/forms/time-picker/index.tsx +7 -3
  16. package/src/components/index.ts +4 -0
  17. package/src/components/layout/key-value/index.tsx +9 -7
  18. package/src/components/layout/resizable/index.tsx +6 -1
  19. package/src/components/navigation/command/index.tsx +24 -6
  20. package/src/components/navigation/link/LinkContext.tsx +3 -1
  21. package/src/components/navigation/pagination/pagination-static.tsx +1 -1
  22. package/src/components/navigation/tabs/index.tsx +30 -9
  23. package/src/components/overlay/responsive-sheet/index.tsx +4 -4
  24. package/src/components/select/helpers.tsx +1 -1
  25. package/src/components/select/multi-select-pro-async.tsx +13 -6
  26. package/src/components/select/multi-select-pro.tsx +3 -4
  27. package/src/components/specialized/flag/Flag.tsx +11 -5
  28. package/src/components/specialized/flag/flag-map.ts +13 -6
  29. package/src/components/specialized/image-with-fallback/index.tsx +9 -4
  30. package/src/components/specialized/presence/index.tsx +2 -3
  31. package/src/components/specialized/token-icon/index.tsx +26 -13
  32. package/src/hooks/audio/useAudioPrefs.ts +8 -3
  33. package/src/hooks/device/useBrowserDetect.ts +5 -1
  34. package/src/hooks/dom/useImageLoader.ts +24 -20
  35. package/src/hooks/dom/useScroll.ts +7 -6
  36. package/src/hooks/events/useEventsBus.ts +19 -5
  37. package/src/hooks/hotkey/useHotkeyChord.ts +11 -4
  38. package/src/hooks/hotkey/useHotkeyHelp.ts +8 -3
  39. package/src/hooks/router/adapter.tsx +3 -1
  40. package/src/hooks/state/storage-quota.ts +27 -0
  41. package/src/hooks/state/useDebouncedCallback.ts +26 -20
  42. package/src/hooks/state/useLocalStorage.ts +7 -13
  43. package/src/hooks/state/useSessionStorage.ts +7 -9
  44. package/src/lib/compose-event-handlers.ts +5 -5
  45. package/src/lib/dialog-service/getDialog.ts +1 -1
  46. package/src/lib/get-element-ref.ts +9 -6
  47. package/src/lib/pretext/pretext.types.ts +25 -70
  48. package/src/lib/pretext/use-pretext.ts +8 -12
  49. package/src/snippets/LazyComponent.tsx +9 -9
  50. package/src/styles/palette/useThemePalette.ts +7 -0
@@ -28,7 +28,7 @@ const CommandDialog = ({ children, ...props }: DialogProps) => {
28
28
  return (
29
29
  <Dialog {...props}>
30
30
  <DialogContent className="overflow-hidden p-0">
31
- <Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
31
+ <Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[data-cmdk-input-wrapper]_svg]:h-5 [&_[data-cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
32
32
  {children}
33
33
  </Command>
34
34
  </DialogContent>
@@ -36,11 +36,15 @@ const CommandDialog = ({ children, ...props }: DialogProps) => {
36
36
  )
37
37
  }
38
38
 
39
+ // The wrapper is marked with `data-cmdk-input-wrapper` rather than the bare
40
+ // `cmdk-input-wrapper`: this div is ours, not cmdk's, and React only forwards
41
+ // unknown attributes to the DOM under a `data-` prefix. CommandDialog's
42
+ // `[&_[data-cmdk-input-wrapper]_svg]` sizing selector matches this same name.
39
43
  const CommandInput = React.forwardRef<
40
44
  React.ElementRef<typeof CommandPrimitive.Input>,
41
45
  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
42
46
  >(({ className, ...props }, ref) => (
43
- <div className="flex items-center border-b px-3" cmdk-input-wrapper="">
47
+ <div className="flex items-center border-b px-3" data-cmdk-input-wrapper="">
44
48
  <MagnifyingGlassIcon className="mr-2 h-4 w-4 shrink-0 opacity-50" />
45
49
  <CommandPrimitive.Input
46
50
  ref={ref}
@@ -59,9 +63,23 @@ const CommandList = React.forwardRef<
59
63
  React.ElementRef<typeof CommandPrimitive.List>,
60
64
  React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
61
65
  >(({ className, style, ...props }, ref) => {
62
- const listRef = React.useRef<HTMLDivElement>(null)
63
-
64
- React.useImperativeHandle(ref, () => listRef.current as HTMLDivElement)
66
+ const listRef = React.useRef<React.ElementRef<typeof CommandPrimitive.List> | null>(null)
67
+
68
+ // The element is needed locally (for the cssText fix below) AND by the
69
+ // caller, so it is captured and forwarded in one callback. useImperativeHandle
70
+ // cannot be used here: it must produce a non-null handle, and the element is
71
+ // genuinely null until the primitive mounts.
72
+ const setRef = React.useCallback(
73
+ (node: React.ElementRef<typeof CommandPrimitive.List> | null) => {
74
+ listRef.current = node
75
+ if (typeof ref === 'function') {
76
+ ref(node)
77
+ } else if (ref) {
78
+ ref.current = node
79
+ }
80
+ },
81
+ [ref]
82
+ )
65
83
 
66
84
  React.useEffect(() => {
67
85
  if (listRef.current) {
@@ -71,7 +89,7 @@ const CommandList = React.forwardRef<
71
89
 
72
90
  return (
73
91
  <CommandPrimitive.List
74
- ref={listRef as any}
92
+ ref={setRef}
75
93
  className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
76
94
  style={{
77
95
  maxHeight: '300px',
@@ -46,7 +46,9 @@ export type LinkComponent = ComponentType<LinkComponentProps>;
46
46
  * makes the two instances agree whatever the bundler decides.
47
47
  */
48
48
  const CONTEXT_KEY = Symbol.for('@djangocfg/ui-core.LinkComponentContext');
49
- const globalScope = globalThis as unknown as Record<symbol, unknown>;
49
+ // `typeof globalThis` carries no index signature, but the object genuinely
50
+ // accepts symbol keys — this asserts a property the runtime has.
51
+ const globalScope = globalThis as Record<symbol, unknown>;
50
52
 
51
53
  export const LinkComponentContext =
52
54
  (globalScope[CONTEXT_KEY] as ReturnType<typeof createContext<LinkComponent | null>> | undefined) ??
@@ -18,7 +18,7 @@ import {
18
18
  Pagination, PaginationContent, PaginationEllipsis, PaginationItem
19
19
  } from './pagination';
20
20
 
21
- export interface DRFPaginatedResponse<T = any> {
21
+ export interface DRFPaginatedResponse<T = unknown> {
22
22
  count: number;
23
23
  page: number;
24
24
  pages: number;
@@ -50,6 +50,17 @@ export interface TabsProps extends React.ComponentPropsWithoutRef<typeof TabsPri
50
50
  storageType?: StorageType
51
51
  }
52
52
 
53
+ /**
54
+ * The part of a trigger's props the mobile sheet re-writes when it clones one.
55
+ *
56
+ * Not the trigger's full prop type: everything else passes through untouched,
57
+ * and naming more than is read would be a claim this file cannot check.
58
+ */
59
+ type TriggerProps = {
60
+ onClick?: (event: React.MouseEvent) => void
61
+ className?: string
62
+ }
63
+
53
64
  const Tabs = React.forwardRef<
54
65
  React.ElementRef<typeof TabsPrimitive.Root>,
55
66
  TabsProps
@@ -67,12 +78,13 @@ const Tabs = React.forwardRef<
67
78
  );
68
79
 
69
80
  // Wrap onValueChange to persist tab changes
81
+ const { onValueChange } = props;
70
82
  const handleValueChange = React.useCallback(
71
83
  (newValue: string) => {
72
84
  if (storageKey) setStoredTab(newValue);
73
- props.onValueChange?.(newValue);
85
+ onValueChange?.(newValue);
74
86
  },
75
- [storageKey, setStoredTab, props.onValueChange],
87
+ [storageKey, setStoredTab, onValueChange],
76
88
  );
77
89
 
78
90
  // Build enhanced props: inject stored defaultValue when no value/defaultValue given
@@ -123,9 +135,14 @@ const Tabs = React.forwardRef<
123
135
  (child) => React.isValidElement(child) && child.type === TabsContent
124
136
  )
125
137
 
126
- // Extract triggers from TabsList children
127
- const triggers = React.isValidElement(tabsList)
128
- ? React.Children.toArray((tabsList.props as any).children)
138
+ // Extract triggers from TabsList children.
139
+ //
140
+ // `isValidElement` narrows to `ReactElement<unknown>` unless it is told the
141
+ // prop shape, which is why reading `.props.children` used to need a cast.
142
+ // Naming the shape is the same assertion, made once and checked: this branch
143
+ // only runs for an element whose `type === TabsList`.
144
+ const triggers = React.isValidElement<{ children?: React.ReactNode }>(tabsList)
145
+ ? React.Children.toArray(tabsList.props.children)
129
146
  : []
130
147
 
131
148
  // Mobile Sheet Navigation
@@ -155,11 +172,15 @@ const Tabs = React.forwardRef<
155
172
  <nav className="flex flex-col gap-2 mt-6">
156
173
  <TabsPrimitive.List className="flex flex-col gap-2">
157
174
  {triggers.map((trigger, index) => {
158
- if (!React.isValidElement(trigger)) return null
175
+ // Typed here rather than cast at each use: the two props
176
+ // this clone actually touches are the two it declares, so
177
+ // `cloneElement` checks the override instead of taking it
178
+ // on faith.
179
+ if (!React.isValidElement<TriggerProps>(trigger)) return null
159
180
 
160
181
  // Clone trigger and wrap in mobile-friendly container
161
- const triggerProps = (trigger as any).props || {};
162
- return React.cloneElement(trigger as React.ReactElement, {
182
+ const triggerProps = trigger.props
183
+ return React.cloneElement(trigger, {
163
184
  key: index,
164
185
  onClick: (e: React.MouseEvent) => {
165
186
  setOpen(false)
@@ -171,7 +192,7 @@ const Tabs = React.forwardRef<
171
192
  "hover:bg-muted",
172
193
  triggerProps.className
173
194
  ),
174
- } as any)
195
+ })
175
196
  })}
176
197
  </TabsPrimitive.List>
177
198
  </nav>
@@ -168,7 +168,7 @@ ResponsiveSheetContent.displayName = "ResponsiveSheetContent"
168
168
  // Header
169
169
  // ─────────────────────────────────────────────────────────────────────────────
170
170
 
171
- interface ResponsiveSheetHeaderProps extends React.HTMLAttributes<HTMLDivElement> {}
171
+ type ResponsiveSheetHeaderProps = React.HTMLAttributes<HTMLDivElement>;
172
172
 
173
173
  function ResponsiveSheetHeader({ className, ...props }: ResponsiveSheetHeaderProps) {
174
174
  const { isMobile } = React.useContext(ResponsiveSheetContext);
@@ -185,7 +185,7 @@ ResponsiveSheetHeader.displayName = "ResponsiveSheetHeader"
185
185
  // Title
186
186
  // ─────────────────────────────────────────────────────────────────────────────
187
187
 
188
- interface ResponsiveSheetTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {}
188
+ type ResponsiveSheetTitleProps = React.HTMLAttributes<HTMLHeadingElement>;
189
189
 
190
190
  function ResponsiveSheetTitle({ className, ...props }: ResponsiveSheetTitleProps) {
191
191
  const { isMobile } = React.useContext(ResponsiveSheetContext);
@@ -202,7 +202,7 @@ ResponsiveSheetTitle.displayName = "ResponsiveSheetTitle"
202
202
  // Description
203
203
  // ─────────────────────────────────────────────────────────────────────────────
204
204
 
205
- interface ResponsiveSheetDescriptionProps extends React.HTMLAttributes<HTMLParagraphElement> {}
205
+ type ResponsiveSheetDescriptionProps = React.HTMLAttributes<HTMLParagraphElement>;
206
206
 
207
207
  function ResponsiveSheetDescription({ className, ...props }: ResponsiveSheetDescriptionProps) {
208
208
  const { isMobile } = React.useContext(ResponsiveSheetContext);
@@ -219,7 +219,7 @@ ResponsiveSheetDescription.displayName = "ResponsiveSheetDescription"
219
219
  // Footer
220
220
  // ─────────────────────────────────────────────────────────────────────────────
221
221
 
222
- interface ResponsiveSheetFooterProps extends React.HTMLAttributes<HTMLDivElement> {}
222
+ type ResponsiveSheetFooterProps = React.HTMLAttributes<HTMLDivElement>;
223
223
 
224
224
  function ResponsiveSheetFooter({ className, ...props }: ResponsiveSheetFooterProps) {
225
225
  const { isMobile } = React.useContext(ResponsiveSheetContext);
@@ -10,7 +10,7 @@ import type { MultiSelectProAsyncOption } from './multi-select-pro-async'
10
10
  /**
11
11
  * Generic option builder config
12
12
  */
13
- export interface OptionBuilderConfig<T = any> {
13
+ export interface OptionBuilderConfig<T = unknown> {
14
14
  /** Extract unique ID/value from item */
15
15
  getValue: (item: T) => string
16
16
  /** Extract main label text */
@@ -248,12 +248,19 @@ export const MultiSelectProAsync = React.forwardRef<MultiSelectProAsyncRef, Mult
248
248
  [animation, animationConfig]
249
249
  )
250
250
 
251
- // Reset on defaultValue change
251
+ // Reset on defaultValue change.
252
+ // Keyed on the serialized contents, not the array identity: callers commonly
253
+ // pass an inline literal, which would otherwise reset the user's selection on
254
+ // every render. `defaultValue` itself is read through a ref for the same reason.
255
+ const defaultValueKey = JSON.stringify(defaultValue)
256
+ const defaultValueRef = React.useRef(defaultValue)
257
+ defaultValueRef.current = defaultValue
258
+
252
259
  React.useEffect(() => {
253
260
  if (resetOnDefaultValueChange) {
254
- setSelectedValues(defaultValue)
261
+ setSelectedValues(defaultValueRef.current)
255
262
  }
256
- }, [JSON.stringify(defaultValue), resetOnDefaultValueChange])
263
+ }, [defaultValueKey, resetOnDefaultValueChange])
257
264
 
258
265
  // Announce changes for screen readers
259
266
  const announce = React.useCallback((message: string) => {
@@ -347,7 +354,7 @@ export const MultiSelectProAsync = React.forwardRef<MultiSelectProAsyncRef, Mult
347
354
  )
348
355
 
349
356
  // Render badge with custom styles
350
- const renderBadge = (option: MultiSelectProAsyncOption, index: number) => {
357
+ const renderBadge = React.useCallback((option: MultiSelectProAsyncOption, index: number) => {
351
358
  const { style, icon: Icon } = option
352
359
  const badgeStyle: React.CSSProperties = {}
353
360
 
@@ -393,7 +400,7 @@ export const MultiSelectProAsync = React.forwardRef<MultiSelectProAsyncRef, Mult
393
400
  )}
394
401
  </Badge>
395
402
  )
396
- }
403
+ }, [animConfig, variant, disabled, toggleOption, translations])
397
404
 
398
405
  // Display value
399
406
  const displayValue = React.useMemo(() => {
@@ -414,7 +421,7 @@ export const MultiSelectProAsync = React.forwardRef<MultiSelectProAsyncRef, Mult
414
421
  )}
415
422
  </div>
416
423
  )
417
- }, [selectedOptions, maxCount, translations, singleLine, variant, disabled, animConfig])
424
+ }, [selectedOptions, maxCount, translations, singleLine, renderBadge])
418
425
 
419
426
  // Render options
420
427
  const renderOptions = () => {
@@ -214,7 +214,6 @@ export const MultiSelectPro = React.forwardRef<MultiSelectProRef, MultiSelectPro
214
214
  }, [flatOptions, selectedValues])
215
215
 
216
216
  // Responsive configuration (reserved for future use)
217
- // @ts-ignore reserved for future use
218
217
  const _responsiveConfig = React.useMemo((): ResponsiveConfig => {
219
218
  if (typeof responsive === 'boolean') {
220
219
  return responsive
@@ -364,7 +363,7 @@ export const MultiSelectPro = React.forwardRef<MultiSelectProRef, MultiSelectPro
364
363
  )
365
364
 
366
365
  // Render badge with custom styles
367
- const renderBadge = (option: MultiSelectProOption, index: number) => {
366
+ const renderBadge = React.useCallback((option: MultiSelectProOption, index: number) => {
368
367
  const { style, icon: Icon } = option
369
368
  const badgeStyle: React.CSSProperties = {}
370
369
 
@@ -410,7 +409,7 @@ export const MultiSelectPro = React.forwardRef<MultiSelectProRef, MultiSelectPro
410
409
  )}
411
410
  </Badge>
412
411
  )
413
- }
412
+ }, [animConfig, variant, disabled, toggleOption, translations])
414
413
 
415
414
  // Display value
416
415
  const displayValue = React.useMemo(() => {
@@ -431,7 +430,7 @@ export const MultiSelectPro = React.forwardRef<MultiSelectProRef, MultiSelectPro
431
430
  )}
432
431
  </div>
433
432
  )
434
- }, [selectedOptions, maxCount, translations, singleLine, variant, disabled, animConfig])
433
+ }, [selectedOptions, maxCount, translations, singleLine, renderBadge])
435
434
 
436
435
  // Render options
437
436
  const renderOptions = () => {
@@ -7,11 +7,20 @@ import { cn } from '../../../lib/utils';
7
7
  import { FLAG_COMPONENTS } from './flag-map';
8
8
  import { getLanguageCountryCode } from './language-to-country';
9
9
 
10
- export interface FlagProps extends Omit<React.SVGAttributes<SVGSVGElement>, 'children'> {
10
+ /**
11
+ * Presentational attributes only. The underlying `country-flag-icons`
12
+ * components type their handlers against an element that cannot exist, so no
13
+ * event handler passed here could be given an honest signature.
14
+ */
15
+ export interface FlagProps {
11
16
  /** ISO 3166-1 alpha-2 country code (e.g. `'US'`, `'JP'`). Case-insensitive. */
12
17
  countryCode: string;
13
18
  /** Optional rounded corners — common for tile / chip presentations. */
14
19
  rounded?: boolean;
20
+ className?: string;
21
+ width?: number | string;
22
+ height?: number | string;
23
+ title?: string;
15
24
  }
16
25
 
17
26
  /**
@@ -22,11 +31,8 @@ export function Flag({ countryCode, rounded, className, ...props }: FlagProps) {
22
31
  const code = countryCode?.toUpperCase();
23
32
  const Component = code ? FLAG_COMPONENTS[code] : undefined;
24
33
  if (!Component) return null;
25
- // `country-flag-icons` types its components against an HTML+SVG intersection;
26
- // we narrow to plain `SVGAttributes` for our consumers.
27
- const Tag = Component as unknown as React.ComponentType<React.SVGAttributes<SVGSVGElement>>;
28
34
  return (
29
- <Tag
35
+ <Component
30
36
  className={cn(
31
37
  'inline-block shrink-0 select-none',
32
38
  rounded && 'overflow-hidden rounded-[2px]',
@@ -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]