@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
@@ -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
  );
@@ -3,17 +3,21 @@
3
3
  import * as React from "react";
4
4
 
5
5
  /**
6
- * Get the ref from a React element without throwing warnings.
6
+ * React 18 exposed `ref` on the element itself; React 19 moved it into `props`.
7
+ * Neither placement is in `ReactElement`'s public type, so the legacy one is
8
+ * declared here rather than asserted away.
7
9
  */
8
- function getElementRef(element: React.ReactElement) {
10
+ type ElementWithLegacyRef = React.ReactElement & { ref?: React.Ref<unknown> };
11
+
12
+ /** Get the ref from a React element without throwing warnings. */
13
+ function getElementRef(element: ElementWithLegacyRef) {
9
14
  if (!React.isValidElement(element)) return undefined;
10
15
 
11
16
  // React <=18 in DEV
12
17
  let getter = Object.getOwnPropertyDescriptor(element.props, "ref")?.get;
13
18
  let mayWarn = getter && "isReactWarning" in getter && getter.isReactWarning;
14
19
  if (mayWarn) {
15
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
16
- return (element as any).ref;
20
+ return element.ref;
17
21
  }
18
22
 
19
23
  // React 19 in DEV
@@ -25,8 +29,7 @@ function getElementRef(element: React.ReactElement) {
25
29
 
26
30
  // Not DEV
27
31
  return (
28
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
29
- (element.props as { ref?: React.Ref<unknown> }).ref || (element as any).ref
32
+ (element.props as { ref?: React.Ref<unknown> }).ref || element.ref
30
33
  );
31
34
  }
32
35
 
@@ -1,72 +1,27 @@
1
1
  // Adapted from jalcoui (MIT) — github.com/jal-co/ui
2
2
  //
3
- // Local mirror of the @chenglou/pretext public API. The runtime dependency
4
- // is declared `optional` (see ui-tools/package.json), so consumers that
5
- // never use BalancedText / pretext-powered tools don't pay for it. To keep
6
- // `pnpm check` green without the dependency installed, we redeclare just
7
- // the types we touch — no `import type from '@chenglou/pretext'`.
8
- //
9
- // Mirrors the public surface as of @chenglou/pretext 0.0.7. If the upstream
10
- // types drift, update here.
11
-
12
- export interface PrepareOptions {
13
- whiteSpace?: 'normal' | 'pre' | 'pre-wrap' | 'pre-line';
14
- }
15
-
16
- // Opaque handles — internal shape doesn't matter to consumers of the hooks.
17
- // We brand them so the two flavours don't get accidentally swapped.
18
- export interface PreparedText {
19
- readonly __brand: 'PreparedText';
20
- }
21
-
22
- export interface PreparedTextWithSegments {
23
- readonly __brand: 'PreparedTextWithSegments';
24
- }
25
-
26
- export interface LayoutResult {
27
- lineCount: number;
28
- height: number;
29
- }
30
-
31
- export interface LayoutLine {
32
- text: string;
33
- width: number;
34
- start: number;
35
- end: number;
36
- }
37
-
38
- export interface LayoutLinesResult {
39
- lineCount: number;
40
- height: number;
41
- lines: LayoutLine[];
42
- }
43
-
44
- export interface WalkLineRange {
45
- width: number;
46
- start: number;
47
- end: number;
48
- }
49
-
50
- export interface PretextModule {
51
- prepare(text: string, font: string, options?: PrepareOptions): PreparedText;
52
- prepareWithSegments(
53
- text: string,
54
- font: string,
55
- options?: PrepareOptions,
56
- ): PreparedTextWithSegments;
57
- layout(
58
- prepared: PreparedText,
59
- maxWidth: number,
60
- lineHeight: number,
61
- ): LayoutResult;
62
- layoutWithLines(
63
- prepared: PreparedTextWithSegments,
64
- maxWidth: number,
65
- lineHeight: number,
66
- ): LayoutLinesResult;
67
- walkLineRanges(
68
- prepared: PreparedTextWithSegments,
69
- maxWidth: number,
70
- visit: (line: WalkLineRange) => void,
71
- ): void;
72
- }
3
+ // Re-export of the @chenglou/pretext public types. A hand-written mirror used
4
+ // to live here to keep `pnpm check` green while the runtime was an optional
5
+ // dependency; it is a hard `dependencies` entry now, and the mirror had already
6
+ // drifted — it declared `LayoutLine.start/end` as `number` where upstream
7
+ // returns a `{ segmentIndex, graphemeIndex }` cursor, so every consumer of
8
+ // `usePretextLines` was typed against a shape the runtime never produces.
9
+
10
+ export type {
11
+ PrepareOptions,
12
+ PreparedText,
13
+ PreparedTextWithSegments,
14
+ LayoutResult,
15
+ LayoutLine,
16
+ LayoutLineRange,
17
+ LayoutLinesResult,
18
+ LayoutCursor,
19
+ } from '@chenglou/pretext';
20
+
21
+ import type * as pretext from '@chenglou/pretext';
22
+
23
+ /** The subset of the module surface the hooks in this folder call. */
24
+ export type PretextModule = Pick<
25
+ typeof pretext,
26
+ 'prepare' | 'prepareWithSegments' | 'layout' | 'layoutWithLines' | 'walkLineRanges'
27
+ >;
@@ -6,15 +6,16 @@
6
6
  //
7
7
  // Powered by Pretext by Cheng Lou — github.com/chenglou/pretext
8
8
  //
9
- // `@chenglou/pretext` is declared as an optionalDependency in
10
- // `packages/ui-tools/package.json` — consumers that never use BalancedText
11
- // (or other pretext-based tools) can install ui-tools without it. The
12
- // `getPretext()` helper uses a CommonJS `require()` so the optional
13
- // dependency is resolved lazily at first use; if it is missing, the
14
- // `require` throws at call site, never at module-load.
9
+ // `@chenglou/pretext` is imported statically because it is ESM-only
10
+ // (`"type": "module"`, and its `exports` map offers only an `import`
11
+ // condition). The previous lazy `require('@chenglou/pretext')` could not
12
+ // resolve it in any runtime: under Vite/ESM `require` is not defined at all,
13
+ // so the first render of `BalancedText` threw `require is not defined`
14
+ // rather than degrading gracefully.
15
15
 
16
16
  'use client';
17
17
 
18
+ import * as pretext from '@chenglou/pretext';
18
19
  import * as React from 'react';
19
20
  import type {
20
21
  PreparedText,
@@ -35,13 +36,8 @@ export type {
35
36
 
36
37
  const isBrowser = typeof window !== 'undefined';
37
38
 
38
- let cachedPretext: PretextModule | null = null;
39
-
40
39
  function getPretext(): PretextModule {
41
- if (cachedPretext) return cachedPretext;
42
- // eslint-disable-next-line @typescript-eslint/no-require-imports
43
- cachedPretext = require('@chenglou/pretext') as PretextModule;
44
- return cachedPretext;
40
+ return pretext;
45
41
  }
46
42
 
47
43
  const EMPTY_LAYOUT: LayoutResult = { lineCount: 0, height: 0 };
@@ -7,7 +7,7 @@
7
7
 
8
8
  'use client';
9
9
 
10
- import React, { Suspense, ReactNode, ComponentType, lazy } from 'react';
10
+ import React, { Suspense, ReactNode, ComponentType, LazyExoticComponent, lazy } from 'react';
11
11
 
12
12
  // Default loading spinner
13
13
  export const DefaultLoader = () => (
@@ -67,13 +67,13 @@ export function LazyWrapper({ children, fallback }: LazyWrapperProps) {
67
67
  * <HeavyComponent someProps={value} />
68
68
  * ```
69
69
  */
70
- export function createLazyComponent<T extends ComponentType<any>>(
71
- importFn: () => Promise<{ default: T }>,
70
+ export function createLazyComponent<P extends object>(
71
+ importFn: () => Promise<{ default: ComponentType<P> }>,
72
72
  fallback?: ReactNode
73
73
  ) {
74
74
  const LazyComponent = lazy(importFn);
75
75
 
76
- return function WrappedLazyComponent(props: React.ComponentProps<T>) {
76
+ return function WrappedLazyComponent(props: P) {
77
77
  return (
78
78
  <Suspense fallback={fallback ?? <DefaultLoader />}>
79
79
  <LazyComponent {...props} />
@@ -95,18 +95,18 @@ export function createLazyComponent<T extends ComponentType<any>>(
95
95
  * ```
96
96
  */
97
97
  export function createLazyNamedComponent<
98
- T extends Record<string, ComponentType<any>>,
99
- K extends keyof T
98
+ P extends object,
99
+ K extends string = string
100
100
  >(
101
- importFn: () => Promise<T>,
101
+ importFn: () => Promise<Record<K, ComponentType<P>>>,
102
102
  exportName: K,
103
103
  fallback?: ReactNode
104
104
  ) {
105
- const LazyComponent = lazy(() =>
105
+ const LazyComponent: LazyExoticComponent<ComponentType<P>> = lazy(() =>
106
106
  importFn().then((mod) => ({ default: mod[exportName] }))
107
107
  );
108
108
 
109
- return function WrappedLazyComponent(props: React.ComponentProps<T[K]>) {
109
+ return function WrappedLazyComponent(props: P) {
110
110
  return (
111
111
  <Suspense fallback={fallback ?? <DefaultLoader />}>
112
112
  <LazyComponent {...props} />
@@ -101,6 +101,13 @@ export function useThemePalette(): ThemePalette {
101
101
  const theme = useResolvedTheme();
102
102
 
103
103
  return useMemo(() => {
104
+ // `theme` is read here so the dependency is real to the linter as well as to
105
+ // us: every getCssColorAsHex call below resolves against the live DOM, whose
106
+ // values change when the theme flips, but nothing in that call chain
107
+ // mentions `theme`. Without this read the memo looks free of it and would be
108
+ // cached across a theme change, returning the previous theme's colors.
109
+ void theme;
110
+
104
111
  return {
105
112
  // Base colors
106
113
  background: getCssColorAsHex('background'),