@classytic/fluid 0.3.3 → 0.3.6

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.
@@ -76,14 +76,18 @@ declare function useCopyToClipboard(resetDelay?: number): UseCopyToClipboardRetu
76
76
  /**
77
77
  * useLocalStorage — Persist state in localStorage with type safety and optional expiry.
78
78
  *
79
- * @param key - The localStorage key
79
+ * Uses `useSyncExternalStore` (React 18+) so the component always reflects
80
+ * the current localStorage value — even when the key changes dynamically,
81
+ * the component re-mounts, or another tab writes to the same key.
82
+ *
83
+ * @param key - The localStorage key (can change dynamically)
80
84
  * @param initialValue - Default value if no stored value exists
81
85
  * @param ttl - Time to live in milliseconds (optional)
82
86
  *
83
87
  * @example
84
88
  * ```tsx
85
89
  * const [theme, setTheme] = useLocalStorage("theme", "light");
86
- * const [cache, setCache] = useLocalStorage("api-cache", {}, 60000); // 1 min TTL
90
+ * const [cache, setCache, clearCache] = useLocalStorage("api-cache", {}, 60000); // 1 min TTL
87
91
  * ```
88
92
  */
89
93
  declare function useLocalStorage<T>(key: string, initialValue: T, ttl?: number): [T, (value: T | ((prev: T) => T)) => void, () => void];
@@ -6,7 +6,7 @@ import { t as useMediaQuery } from "../use-media-query-BnVNIKT4.mjs";
6
6
  import { t as useScrollDetection } from "../use-scroll-detection-CsgsQYvy.mjs";
7
7
  import { n as useDebouncedCallback, t as useDebounce } from "../use-debounce-xmZucz5e.mjs";
8
8
  import { t as useKeyboardShortcut } from "../use-keyboard-shortcut-Bl6YM5Q7.mjs";
9
- import { useCallback, useEffect, useRef, useState } from "react";
9
+ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
10
10
  import { useRouter, useSearchParams } from "next/navigation";
11
11
 
12
12
  //#region src/hooks/use-base-search.ts
@@ -391,53 +391,93 @@ const storage = {
391
391
 
392
392
  //#endregion
393
393
  //#region src/hooks/use-local-storage.ts
394
+ function parseSnapshot(rawValue, initialValue) {
395
+ try {
396
+ const parsed = JSON.parse(rawValue);
397
+ if (parsed && typeof parsed === "object" && "__expiresAt" in parsed && typeof parsed.__expiresAt === "number") {
398
+ if (Date.now() > parsed.__expiresAt) return {
399
+ value: initialValue,
400
+ expired: true
401
+ };
402
+ return {
403
+ value: parsed.value ?? initialValue,
404
+ expired: false
405
+ };
406
+ }
407
+ return {
408
+ value: parsed ?? initialValue,
409
+ expired: false
410
+ };
411
+ } catch {
412
+ return {
413
+ value: initialValue,
414
+ expired: false
415
+ };
416
+ }
417
+ }
394
418
  /**
395
419
  * useLocalStorage — Persist state in localStorage with type safety and optional expiry.
396
420
  *
397
- * @param key - The localStorage key
421
+ * Uses `useSyncExternalStore` (React 18+) so the component always reflects
422
+ * the current localStorage value — even when the key changes dynamically,
423
+ * the component re-mounts, or another tab writes to the same key.
424
+ *
425
+ * @param key - The localStorage key (can change dynamically)
398
426
  * @param initialValue - Default value if no stored value exists
399
427
  * @param ttl - Time to live in milliseconds (optional)
400
428
  *
401
429
  * @example
402
430
  * ```tsx
403
431
  * const [theme, setTheme] = useLocalStorage("theme", "light");
404
- * const [cache, setCache] = useLocalStorage("api-cache", {}, 60000); // 1 min TTL
432
+ * const [cache, setCache, clearCache] = useLocalStorage("api-cache", {}, 60000); // 1 min TTL
405
433
  * ```
406
434
  */
407
435
  function useLocalStorage(key, initialValue, ttl) {
408
- const [storedValue, setStoredValue] = useState(() => {
409
- const item = storage.get(key, initialValue);
410
- return item !== null ? item : initialValue;
411
- });
412
- const keyRef = useRef(key);
413
436
  const ttlRef = useRef(ttl);
414
- useEffect(() => {
415
- keyRef.current = key;
416
- ttlRef.current = ttl;
417
- }, [key, ttl]);
418
- const setValue = useCallback((value) => {
419
- setStoredValue((prev) => {
420
- const nextValue = value instanceof Function ? value(prev) : value;
421
- storage.set(keyRef.current, nextValue, ttlRef.current);
422
- return nextValue;
423
- });
424
- }, []);
425
- const removeValue = useCallback(() => {
426
- storage.remove(keyRef.current);
427
- setStoredValue(initialValue);
428
- }, [initialValue]);
429
- useEffect(() => {
437
+ ttlRef.current = ttl;
438
+ const initialValueRef = useRef(initialValue);
439
+ const serializedInitialRef = useRef(() => JSON.stringify(initialValue));
440
+ const getSnapshot = useCallback(() => {
441
+ if (typeof window === "undefined") return serializedInitialRef.current();
442
+ return window.localStorage.getItem(key) ?? serializedInitialRef.current();
443
+ }, [key]);
444
+ const getServerSnapshot = useCallback(() => serializedInitialRef.current(), []);
445
+ const rawValue = useSyncExternalStore(useCallback((onStoreChange) => {
430
446
  const handleStorage = (e) => {
431
- if (e.key === keyRef.current) {
432
- const item = storage.get(keyRef.current, initialValue);
433
- setStoredValue(item !== null ? item : initialValue);
434
- }
447
+ if (e.key === key || e.key === null) onStoreChange();
435
448
  };
436
449
  window.addEventListener("storage", handleStorage);
437
- return () => window.removeEventListener("storage", handleStorage);
438
- }, [initialValue]);
450
+ const handleLocal = (e) => {
451
+ if (e.detail?.key === key) onStoreChange();
452
+ };
453
+ window.addEventListener("local-storage-change", handleLocal);
454
+ return () => {
455
+ window.removeEventListener("storage", handleStorage);
456
+ window.removeEventListener("local-storage-change", handleLocal);
457
+ };
458
+ }, [key]), getSnapshot, getServerSnapshot);
459
+ const parsedSnapshot = useMemo(() => parseSnapshot(rawValue, initialValueRef.current), [rawValue]);
460
+ useEffect(() => {
461
+ if (!parsedSnapshot.expired || typeof window === "undefined") return;
462
+ storage.remove(key);
463
+ window.dispatchEvent(new CustomEvent("local-storage-change", { detail: { key } }));
464
+ }, [key, parsedSnapshot.expired]);
465
+ const notifyChange = useCallback(() => {
466
+ window.dispatchEvent(new CustomEvent("local-storage-change", { detail: { key } }));
467
+ }, [key]);
468
+ const setValue = useCallback((updater) => {
469
+ const init = initialValueRef.current;
470
+ const current = parseSnapshot(typeof window === "undefined" ? JSON.stringify(init) : window.localStorage.getItem(key) ?? JSON.stringify(init), init).value;
471
+ const nextValue = updater instanceof Function ? updater(current) : updater;
472
+ storage.set(key, nextValue, ttlRef.current);
473
+ notifyChange();
474
+ }, [key, notifyChange]);
475
+ const removeValue = useCallback(() => {
476
+ storage.remove(key);
477
+ notifyChange();
478
+ }, [key, notifyChange]);
439
479
  return [
440
- storedValue,
480
+ parsedSnapshot.value,
441
481
  setValue,
442
482
  removeValue
443
483
  ];
@@ -49,6 +49,8 @@ interface NavItem extends NavPermissions {
49
49
  url: string;
50
50
  icon?: LucideIcon;
51
51
  isActive?: boolean;
52
+ /** When true, the collapsible sub-menu is expanded by default (even when not active). */
53
+ defaultOpen?: boolean;
52
54
  badge?: string | number;
53
55
  items?: NavSubItem[];
54
56
  }
@@ -379,7 +379,7 @@ function SidebarBrand({ title, icon, href = "/", className, tooltip }) {
379
379
  function SidebarNavItem({ item, onClick }) {
380
380
  const hasSubItems = item.items && item.items.length > 0;
381
381
  const Icon = item.icon;
382
- const [open, setOpen] = useState(item.isActive ?? false);
382
+ const [open, setOpen] = useState(item.defaultOpen ?? item.isActive ?? false);
383
383
  if (hasSubItems) return /* @__PURE__ */ jsx(SidebarMenuItem, { children: /* @__PURE__ */ jsxs(Collapsible, {
384
384
  open,
385
385
  onOpenChange: setOpen,
package/dist/forms.mjs CHANGED
@@ -372,6 +372,7 @@ function SelectInput({ control, items = [], groups = [], name, label, placeholde
372
372
  const rawValue = field ? field.value?.toString() : localValue;
373
373
  const handleChange = (newValue) => {
374
374
  const actualValue = newValue === CLEAR_VALUE ? "" : newValue;
375
+ if ((field ? String(field.value ?? "") : localValue) === actualValue) return;
375
376
  if (field) field.onChange(actualValue);
376
377
  else setLocalValue(actualValue);
377
378
  onValueChange?.(actualValue);
@@ -1475,18 +1476,32 @@ TagChoiceInput.displayName = "TagChoiceInput";
1475
1476
  */
1476
1477
  function ComboboxInput({ control, name, label, placeholder = "Select...", emptyText = "No items found.", description, helperText, required, disabled, items = [], className, labelClassName, inputClassName, onValueChange, renderOption, value: propValue, onChange: propOnChange }) {
1477
1478
  const descriptionText = description || helperText;
1478
- const handleValueChange = useCallback((newItem, field) => {
1479
+ const resolvedMapRef = useRef(/* @__PURE__ */ new Map());
1480
+ const currentValueRef = useRef("");
1481
+ useEffect(() => {
1482
+ resolvedMapRef.current = new Map(items.map((item) => [item.value, item]));
1483
+ }, [items]);
1484
+ const onValueChangeRef = useRef(onValueChange);
1485
+ onValueChangeRef.current = onValueChange;
1486
+ const propOnChangeRef = useRef(propOnChange);
1487
+ propOnChangeRef.current = propOnChange;
1488
+ const fieldRef = useRef();
1489
+ const handleValueChange = useCallback((newItem) => {
1479
1490
  const safeValue = newItem?.value || "";
1491
+ if (safeValue === currentValueRef.current) return;
1492
+ const field = fieldRef.current;
1480
1493
  if (field) field.onChange(safeValue);
1481
- else if (propOnChange) propOnChange(safeValue);
1482
- onValueChange?.(safeValue);
1483
- }, [propOnChange, onValueChange]);
1494
+ else if (propOnChangeRef.current) propOnChangeRef.current(safeValue);
1495
+ onValueChangeRef.current?.(safeValue);
1496
+ }, []);
1484
1497
  const renderCombobox = (currentValue, field, isDisabled, fieldState) => {
1498
+ fieldRef.current = field;
1499
+ currentValueRef.current = currentValue ?? "";
1485
1500
  const ariaDescribedBy = [descriptionText ? `${name}-description` : void 0, fieldState?.invalid ? `${name}-error` : void 0].filter(Boolean).join(" ") || void 0;
1486
1501
  return /* @__PURE__ */ jsxs(Combobox, {
1487
1502
  items,
1488
- value: currentValue ? items.find((item) => item.value === currentValue) ?? null : null,
1489
- onValueChange: (item) => handleValueChange(item, field),
1503
+ value: currentValue ? resolvedMapRef.current.get(currentValue) ?? items.find((item) => item.value === currentValue) ?? null : null,
1504
+ onValueChange: handleValueChange,
1490
1505
  disabled: isDisabled,
1491
1506
  children: [/* @__PURE__ */ jsx(ComboboxInput$1, {
1492
1507
  placeholder,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/fluid",
3
- "version": "0.3.3",
3
+ "version": "0.3.6",
4
4
  "type": "module",
5
5
  "description": "Fluid UI - Custom components built on shadcn/ui and base ui by Classytic",
6
6
  "main": "./dist/index.mjs",