@djangocfg/ui-core 2.1.480 → 2.1.482

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-core",
3
- "version": "2.1.480",
3
+ "version": "2.1.482",
4
4
  "description": "Pure React UI component library without Next.js dependencies - for Electron, Vite, CRA apps",
5
5
  "keywords": [
6
6
  "ui-components",
@@ -128,7 +128,7 @@
128
128
  "check:contrast": "node scripts/check-preset-contrast.mjs"
129
129
  },
130
130
  "peerDependencies": {
131
- "@djangocfg/i18n": "^2.1.480",
131
+ "@djangocfg/i18n": "^2.1.482",
132
132
  "consola": "^3.4.2",
133
133
  "lucide-react": "^0.545.0",
134
134
  "moment": "^2.30.1",
@@ -206,8 +206,8 @@
206
206
  "@chenglou/pretext": "^0.0.8"
207
207
  },
208
208
  "devDependencies": {
209
- "@djangocfg/i18n": "^2.1.480",
210
- "@djangocfg/typescript-config": "^2.1.480",
209
+ "@djangocfg/i18n": "^2.1.482",
210
+ "@djangocfg/typescript-config": "^2.1.482",
211
211
  "@types/node": "^24.13.3",
212
212
  "@types/react": "19.2.15",
213
213
  "@types/react-dom": "19.2.3",
@@ -177,7 +177,11 @@ const TagsInput = React.forwardRef<HTMLDivElement, TagsInputRootProps>(
177
177
  const collectionRef = React.useRef<HTMLDivElement>(null);
178
178
  const inputRef = React.useRef<HTMLInputElement>(null);
179
179
 
180
- const id = idProp ?? React.useId();
180
+ // useId must be called unconditionally — `idProp ?? React.useId()` skips the
181
+ // hook whenever a caller passes `id`, so the hook count differs between
182
+ // renders/instances and React crashes (#310). Call it, then prefer idProp.
183
+ const generatedId = React.useId();
184
+ const id = idProp ?? generatedId;
181
185
  const inputId = React.useId();
182
186
  const labelId = React.useId();
183
187
 
@@ -195,6 +195,56 @@ export function CountrySelect({
195
195
  onChange?.(value.filter((v) => v !== code))
196
196
  }, [value, onChange])
197
197
 
198
+ // Used only by the dropdown variant below, but MUST be declared before the
199
+ // `inline` early return — a hook after a conditional return changes the hook
200
+ // count when `variant` differs between renders and crashes React (#310).
201
+ const displayValue = React.useMemo(() => {
202
+ if (selectedCountries.length === 0) {
203
+ return <span className="text-muted-foreground">{resolvedPlaceholder}</span>
204
+ }
205
+
206
+ if (!multiple && selectedCountries.length === 1) {
207
+ const country = selectedCountries[0]!;
208
+ return (
209
+ <div className="flex items-center gap-2">
210
+ <Flag countryCode={country.code} rounded className="h-3 w-4 shrink-0" />
211
+ <span>{country.name}</span>
212
+ </div>
213
+ );
214
+ }
215
+
216
+ const displayed = selectedCountries.slice(0, maxDisplay)
217
+ const remaining = selectedCountries.length - maxDisplay
218
+
219
+ return (
220
+ <div className="flex flex-wrap gap-1">
221
+ {displayed.map((country) => (
222
+ <Badge
223
+ key={country.code}
224
+ variant="secondary"
225
+ className="mr-1 text-xs"
226
+ >
227
+ <Flag countryCode={country.code} rounded className="mr-1 h-3 w-4 shrink-0" />
228
+ {country.name}
229
+ <button
230
+ className="ml-1 rounded-full hover:bg-muted-foreground/20"
231
+ onClick={(e) => handleRemove(country.code, e)}
232
+ disabled={disabled}
233
+ aria-label={`Remove ${country.name}`}
234
+ >
235
+ <X className="h-3 w-3" />
236
+ </button>
237
+ </Badge>
238
+ ))}
239
+ {remaining > 0 && (
240
+ <Badge variant="outline" className="text-xs">
241
+ {moreItemsLabel(remaining)}
242
+ </Badge>
243
+ )}
244
+ </div>
245
+ )
246
+ }, [selectedCountries, maxDisplay, resolvedPlaceholder, disabled, multiple, handleRemove, moreItemsLabel])
247
+
198
248
  // Inline variant
199
249
  if (variant === 'inline') {
200
250
  return (
@@ -276,54 +326,7 @@ export function CountrySelect({
276
326
  );
277
327
  }
278
328
 
279
- // Dropdown variant
280
- const displayValue = React.useMemo(() => {
281
- if (selectedCountries.length === 0) {
282
- return <span className="text-muted-foreground">{resolvedPlaceholder}</span>
283
- }
284
-
285
- if (!multiple && selectedCountries.length === 1) {
286
- const country = selectedCountries[0]!;
287
- return (
288
- <div className="flex items-center gap-2">
289
- <Flag countryCode={country.code} rounded className="h-3 w-4 shrink-0" />
290
- <span>{country.name}</span>
291
- </div>
292
- );
293
- }
294
-
295
- const displayed = selectedCountries.slice(0, maxDisplay)
296
- const remaining = selectedCountries.length - maxDisplay
297
-
298
- return (
299
- <div className="flex flex-wrap gap-1">
300
- {displayed.map((country) => (
301
- <Badge
302
- key={country.code}
303
- variant="secondary"
304
- className="mr-1 text-xs"
305
- >
306
- <Flag countryCode={country.code} rounded className="mr-1 h-3 w-4 shrink-0" />
307
- {country.name}
308
- <button
309
- className="ml-1 rounded-full hover:bg-muted-foreground/20"
310
- onClick={(e) => handleRemove(country.code, e)}
311
- disabled={disabled}
312
- aria-label={`Remove ${country.name}`}
313
- >
314
- <X className="h-3 w-3" />
315
- </button>
316
- </Badge>
317
- ))}
318
- {remaining > 0 && (
319
- <Badge variant="outline" className="text-xs">
320
- {moreItemsLabel(remaining)}
321
- </Badge>
322
- )}
323
- </div>
324
- )
325
- }, [selectedCountries, maxDisplay, resolvedPlaceholder, disabled, multiple, handleRemove, moreItemsLabel])
326
-
329
+ // Dropdown variant (displayValue is memoized above, before the inline guard)
327
330
  return (
328
331
  <Popover
329
332
  open={open}
@@ -209,6 +209,58 @@ export function LanguageSelect({
209
209
  onChange?.(value.filter((v) => v !== code))
210
210
  }, [value, onChange])
211
211
 
212
+ // Used only by the dropdown variant below, but MUST be declared before the
213
+ // `inline` early return — a hook after a conditional return changes the hook
214
+ // count when `variant` differs between renders and crashes React (#310).
215
+ const displayValue = React.useMemo(() => {
216
+ if (selectedLanguages.length === 0) {
217
+ return <span className="text-muted-foreground">{resolvedPlaceholder}</span>
218
+ }
219
+
220
+ if (!multiple && selectedLanguages.length === 1) {
221
+ const language = selectedLanguages[0]!;
222
+ return (
223
+ <div className="flex items-center gap-2">
224
+ {showFlag && <LanguageFlag code={language.code} className="h-3 w-4" rounded />}
225
+ {showCode && <span className="text-xs text-muted-foreground uppercase">{language.code}</span>}
226
+ <span>{language.name}</span>
227
+ </div>
228
+ );
229
+ }
230
+
231
+ const displayed = selectedLanguages.slice(0, maxDisplay)
232
+ const remaining = selectedLanguages.length - maxDisplay
233
+
234
+ return (
235
+ <div className="flex flex-wrap gap-1">
236
+ {displayed.map((language) => (
237
+ <Badge
238
+ key={language.code}
239
+ variant="secondary"
240
+ className="mr-1 text-xs"
241
+ >
242
+ {showFlag && <LanguageFlag code={language.code} className="mr-1 h-3 w-4" rounded />}
243
+ {showCode && <span className="mr-1 text-muted-foreground uppercase">{language.code}</span>}
244
+ {language.name}
245
+ <button
246
+ className="ml-1 rounded-full hover:bg-muted-foreground/20"
247
+ onClick={(e) => handleRemove(language.code, e)}
248
+ disabled={disabled}
249
+ aria-label={`Remove ${language.name}`}
250
+ >
251
+ <X className="h-3 w-3" />
252
+ </button>
253
+ </Badge>
254
+ ))}
255
+ {remaining > 0 && (
256
+ <Badge variant="outline" className="text-xs">
257
+ {moreItemsLabel(remaining)}
258
+ </Badge>
259
+ )}
260
+ </div>
261
+ )
262
+ }, [selectedLanguages, maxDisplay, resolvedPlaceholder, disabled, multiple, handleRemove, moreItemsLabel, showFlag, showCode])
263
+
212
264
  // Inline variant
213
265
  if (variant === 'inline') {
214
266
  return (
@@ -307,56 +359,7 @@ export function LanguageSelect({
307
359
  );
308
360
  }
309
361
 
310
- // Dropdown variant
311
- const displayValue = React.useMemo(() => {
312
- if (selectedLanguages.length === 0) {
313
- return <span className="text-muted-foreground">{resolvedPlaceholder}</span>
314
- }
315
-
316
- if (!multiple && selectedLanguages.length === 1) {
317
- const language = selectedLanguages[0]!;
318
- return (
319
- <div className="flex items-center gap-2">
320
- {showFlag && <LanguageFlag code={language.code} className="h-3 w-4" rounded />}
321
- {showCode && <span className="text-xs text-muted-foreground uppercase">{language.code}</span>}
322
- <span>{language.name}</span>
323
- </div>
324
- );
325
- }
326
-
327
- const displayed = selectedLanguages.slice(0, maxDisplay)
328
- const remaining = selectedLanguages.length - maxDisplay
329
-
330
- return (
331
- <div className="flex flex-wrap gap-1">
332
- {displayed.map((language) => (
333
- <Badge
334
- key={language.code}
335
- variant="secondary"
336
- className="mr-1 text-xs"
337
- >
338
- {showFlag && <LanguageFlag code={language.code} className="mr-1 h-3 w-4" rounded />}
339
- {showCode && <span className="mr-1 text-muted-foreground uppercase">{language.code}</span>}
340
- {language.name}
341
- <button
342
- className="ml-1 rounded-full hover:bg-muted-foreground/20"
343
- onClick={(e) => handleRemove(language.code, e)}
344
- disabled={disabled}
345
- aria-label={`Remove ${language.name}`}
346
- >
347
- <X className="h-3 w-3" />
348
- </button>
349
- </Badge>
350
- ))}
351
- {remaining > 0 && (
352
- <Badge variant="outline" className="text-xs">
353
- {moreItemsLabel(remaining)}
354
- </Badge>
355
- )}
356
- </div>
357
- )
358
- }, [selectedLanguages, maxDisplay, resolvedPlaceholder, disabled, multiple, handleRemove, moreItemsLabel, showFlag, showCode])
359
-
362
+ // Dropdown variant (displayValue is memoized above, before the inline guard)
360
363
  return (
361
364
  <Popover
362
365
  open={open}
@@ -5,33 +5,41 @@ import { useDebugValue } from 'react';
5
5
  type DebugValue = Record<string, unknown> | unknown[] | null | undefined;
6
6
 
7
7
  export function useDebugTools(values: DebugValue, prefix = '') {
8
+ // useDebugValue must be called EXACTLY ONCE and UNCONDITIONALLY. The prior
9
+ // version called it inside branches and per-array-element/object-key loops,
10
+ // so the hook count varied with the shape of `values` between renders —
11
+ // a rules-of-hooks violation (React #310 territory). Instead, call it once
12
+ // with a lazy formatter that builds the full multi-line label; the formatter
13
+ // still only runs when React DevTools inspects the hook, so nothing is
14
+ // eagerly serialized in normal renders.
15
+ useDebugValue(values, (v) => formatDebugValues(v, prefix));
16
+ }
17
+
18
+ function formatDebugValues(values: DebugValue, prefix: string): string {
8
19
  if (values === null || values === undefined) {
9
- useDebugValue(values, () => `${prefix}: ${values === null ? 'null' : 'undefined'}`);
10
- return;
20
+ return `${prefix}: ${values === null ? 'null' : 'undefined'}`;
11
21
  }
12
22
 
13
23
  if (Array.isArray(values)) {
14
- values.forEach((value, index) => {
15
- useDebugValue(value, (v) => {
24
+ return values
25
+ .map((value, index) => {
16
26
  const label = prefix ? `${prefix}[${index}]` : `[${index}]`;
17
- return `${label}: ${formatValue(v)}`;
18
- });
19
- });
20
- return;
27
+ return `${label}: ${formatValue(value)}`;
28
+ })
29
+ .join('\n');
21
30
  }
22
31
 
23
32
  if (typeof values === 'object') {
24
- for (const [key, value] of Object.entries(values)) {
25
- useDebugValue(value, (v) => {
33
+ return Object.entries(values)
34
+ .map(([key, value]) => {
26
35
  const label = prefix ? `${prefix}.${key}` : key;
27
- return `${label}: ${formatValue(v)}`;
28
- });
29
- }
30
- return;
36
+ return `${label}: ${formatValue(value)}`;
37
+ })
38
+ .join('\n');
31
39
  }
32
40
 
33
- // Handle primitive values
34
- useDebugValue(values, (v) => `${prefix}: ${formatValue(v)}`);
41
+ // Primitive
42
+ return `${prefix}: ${formatValue(values)}`;
35
43
  }
36
44
 
37
45
  function formatValue(value: unknown): string {