@djangocfg/ui-core 2.1.225 → 2.1.227

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.225",
3
+ "version": "2.1.227",
4
4
  "description": "Pure React UI component library without Next.js dependencies - for Electron, Vite, CRA apps",
5
5
  "keywords": [
6
6
  "ui-components",
@@ -81,7 +81,7 @@
81
81
  "playground": "playground dev"
82
82
  },
83
83
  "peerDependencies": {
84
- "@djangocfg/i18n": "^2.1.225",
84
+ "@djangocfg/i18n": "^2.1.227",
85
85
  "react-device-detect": "^2.2.3",
86
86
  "consola": "^3.4.2",
87
87
  "lucide-react": "^0.545.0",
@@ -143,9 +143,9 @@
143
143
  "vaul": "1.1.2"
144
144
  },
145
145
  "devDependencies": {
146
- "@djangocfg/i18n": "^2.1.225",
146
+ "@djangocfg/i18n": "^2.1.227",
147
147
  "@djangocfg/playground": "workspace:*",
148
- "@djangocfg/typescript-config": "^2.1.225",
148
+ "@djangocfg/typescript-config": "^2.1.227",
149
149
  "@types/node": "^24.7.2",
150
150
  "@types/react": "^19.1.0",
151
151
  "@types/react-dom": "^19.1.0",
@@ -5,9 +5,90 @@ import * as React from 'react';
5
5
  import * as AccordionPrimitive from '@radix-ui/react-accordion';
6
6
  import { ChevronDownIcon } from '@radix-ui/react-icons';
7
7
 
8
+ import { useStoredValue, type StorageType, type UseStoredValueOptions } from '../hooks/useStoredValue';
8
9
  import { cn } from '../lib/utils';
9
10
 
10
- const Accordion = AccordionPrimitive.Root
11
+ type AccordionSingleProps = React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Root> & {
12
+ type: 'single'
13
+ storageKey?: string
14
+ storageType?: StorageType
15
+ storageTtl?: number
16
+ }
17
+
18
+ type AccordionMultipleProps = React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Root> & {
19
+ type: 'multiple'
20
+ storageKey?: string
21
+ storageType?: StorageType
22
+ storageTtl?: number
23
+ }
24
+
25
+ export type AccordionProps = AccordionSingleProps | AccordionMultipleProps
26
+
27
+ const Accordion = React.forwardRef<
28
+ React.ElementRef<typeof AccordionPrimitive.Root>,
29
+ AccordionProps
30
+ >(({ storageKey, storageType, storageTtl, ...props }, ref) => {
31
+ const storageOptions: UseStoredValueOptions | undefined =
32
+ storageKey ? { storage: storageType ?? 'local', ttl: storageTtl } : undefined;
33
+
34
+ const isSingle = props.type === 'single';
35
+
36
+ // For single: persist string; for multiple: persist string[]
37
+ const initialSingle = (props as AccordionSingleProps).defaultValue ?? ((props as AccordionSingleProps).value as string | undefined) ?? '';
38
+ const initialMultiple = (props as AccordionMultipleProps).defaultValue ?? ((props as AccordionMultipleProps).value as string[] | undefined) ?? [];
39
+
40
+ const [storedSingle, setStoredSingle] = useStoredValue<string>(
41
+ isSingle ? storageKey : undefined,
42
+ initialSingle,
43
+ storageOptions,
44
+ );
45
+ const [storedMultiple, setStoredMultiple] = useStoredValue<string[]>(
46
+ !isSingle ? storageKey : undefined,
47
+ initialMultiple,
48
+ storageOptions,
49
+ );
50
+
51
+ if (isSingle) {
52
+ const { onValueChange, value, defaultValue, ...rest } = props as AccordionSingleProps & { onValueChange?: (v: string) => void };
53
+ const handleValueChange = storageKey
54
+ ? (newValue: string) => { setStoredSingle(newValue); onValueChange?.(newValue); }
55
+ : onValueChange;
56
+ const extraProps = storageKey && value === undefined && storedSingle
57
+ ? { defaultValue: storedSingle }
58
+ : defaultValue !== undefined ? { defaultValue } : {};
59
+
60
+ return (
61
+ <AccordionPrimitive.Root
62
+ ref={ref}
63
+ type="single"
64
+ value={value}
65
+ onValueChange={handleValueChange}
66
+ {...extraProps}
67
+ {...rest}
68
+ />
69
+ );
70
+ }
71
+
72
+ const { onValueChange, value, defaultValue, ...rest } = props as AccordionMultipleProps & { onValueChange?: (v: string[]) => void };
73
+ const handleValueChange = storageKey
74
+ ? (newValue: string[]) => { setStoredMultiple(newValue); onValueChange?.(newValue); }
75
+ : onValueChange;
76
+ const extraProps = storageKey && value === undefined && storedMultiple.length > 0
77
+ ? { defaultValue: storedMultiple }
78
+ : defaultValue !== undefined ? { defaultValue } : {};
79
+
80
+ return (
81
+ <AccordionPrimitive.Root
82
+ ref={ref}
83
+ type="multiple"
84
+ value={value}
85
+ onValueChange={handleValueChange}
86
+ {...extraProps}
87
+ {...rest}
88
+ />
89
+ );
90
+ })
91
+ Accordion.displayName = "Accordion"
11
92
 
12
93
  const AccordionItem = React.forwardRef<
13
94
  React.ElementRef<typeof AccordionPrimitive.Item>,
@@ -4,6 +4,7 @@ import { Check, ChevronsUpDown } from 'lucide-react';
4
4
  import * as React from 'react';
5
5
 
6
6
  import { useTypedT, type I18nTranslations } from '@djangocfg/i18n';
7
+ import { useStoredValue, type StorageType, type UseStoredValueOptions } from '../hooks/useStoredValue';
7
8
  import { cn } from '../lib/utils';
8
9
  import { Button } from './button';
9
10
  import {
@@ -31,11 +32,38 @@ export interface ComboboxProps {
31
32
  renderValue?: (option: ComboboxOption | undefined) => React.ReactNode
32
33
  /** Custom filter function. If provided, replaces default filtering logic. */
33
34
  filterFunction?: (option: ComboboxOption, search: string) => boolean
35
+ /**
36
+ * When provided, the selected value is persisted under this key.
37
+ *
38
+ * On mount: if the stored value exists in the current options list,
39
+ * it becomes the initial selection. If options are not yet loaded
40
+ * (empty array), the component waits — it applies the stored value
41
+ * once options become non-empty (see autoSelectFromOptions).
42
+ *
43
+ * @example storageKey="selected-queue"
44
+ */
45
+ storageKey?: string
46
+ /** @default 'local' */
47
+ storageType?: StorageType
48
+ /** TTL in ms */
49
+ storageTtl?: number
50
+ /**
51
+ * When true AND storageKey is set: after options load/change, if the
52
+ * stored value is present in the new options list it is auto-selected
53
+ * (calls onValueChange). If the stored value is no longer in the list
54
+ * the selection is cleared to "".
55
+ *
56
+ * When false (default): stored value only seeds the initial value on
57
+ * first render — subsequent option changes don't auto-correct the value.
58
+ *
59
+ * @default false
60
+ */
61
+ autoSelectFromOptions?: boolean
34
62
  }
35
63
 
36
64
  export function Combobox({
37
65
  options,
38
- value,
66
+ value: controlledValue,
39
67
  onValueChange,
40
68
  placeholder,
41
69
  searchPlaceholder,
@@ -45,12 +73,77 @@ export function Combobox({
45
73
  renderOption,
46
74
  renderValue,
47
75
  filterFunction,
76
+ storageKey,
77
+ storageType,
78
+ storageTtl,
79
+ autoSelectFromOptions = false,
48
80
  }: ComboboxProps) {
49
81
  const t = useTypedT<I18nTranslations>()
50
82
  const [open, setOpen] = React.useState(false)
51
83
  const [search, setSearch] = React.useState("")
52
84
  const scrollRef = React.useRef<HTMLDivElement>(null)
53
85
 
86
+ const storageOptions: UseStoredValueOptions | undefined =
87
+ storageKey ? { storage: storageType ?? 'local', ttl: storageTtl } : undefined;
88
+
89
+ const [storedValue, setStoredValue] = useStoredValue<string>(
90
+ storageKey,
91
+ controlledValue ?? '',
92
+ storageOptions,
93
+ );
94
+
95
+ // Internal selection state — used only when parent doesn't control `value`.
96
+ // Seeded from storedValue (which is initialValue / '' on SSR, real value post-hydration).
97
+ const [internalValue, setInternalValue] = React.useState<string>(
98
+ controlledValue !== undefined ? controlledValue : storedValue,
99
+ );
100
+
101
+ // The effective selected value: prefer controlled, fall back to internal
102
+ const value = controlledValue !== undefined ? controlledValue : internalValue;
103
+
104
+ // autoSelectFromOptions: when options become available, validate stored value.
105
+ // Runs whenever options change (async loads, filters, etc.)
106
+ // Only acts when: storageKey is set, autoSelectFromOptions=true, options non-empty.
107
+ // Guards:
108
+ // - don't override a value the user explicitly set to "" (intentional clear)
109
+ // - don't run while options are still loading (length === 0)
110
+ const autoSelectApplied = React.useRef(false);
111
+
112
+ React.useEffect(() => {
113
+ if (!storageKey || !autoSelectFromOptions || options.length === 0) return;
114
+
115
+ // Only auto-apply once per storageKey lifetime to avoid fighting user selections.
116
+ // Reset when storageKey changes (handled by the key dependency below).
117
+ if (autoSelectApplied.current) return;
118
+ autoSelectApplied.current = true;
119
+
120
+ const savedValue = storedValue;
121
+ if (!savedValue) return; // nothing saved
122
+
123
+ const existsInOptions = options.some((o) => o.value === savedValue && !o.disabled);
124
+
125
+ if (existsInOptions) {
126
+ // Restore saved selection
127
+ if (controlledValue === undefined) {
128
+ setInternalValue(savedValue);
129
+ }
130
+ onValueChange?.(savedValue);
131
+ } else {
132
+ // Saved value no longer valid — clear it
133
+ setStoredValue('');
134
+ if (controlledValue === undefined) {
135
+ setInternalValue('');
136
+ }
137
+ onValueChange?.('');
138
+ }
139
+ // eslint-disable-next-line react-hooks/exhaustive-deps
140
+ }, [options, storageKey, autoSelectFromOptions]);
141
+
142
+ // Reset autoSelectApplied when storageKey changes
143
+ React.useEffect(() => {
144
+ autoSelectApplied.current = false;
145
+ }, [storageKey]);
146
+
54
147
  // Resolve translations
55
148
  const resolvedPlaceholder = placeholder ?? t('ui.select.placeholder')
56
149
  const resolvedSearchPlaceholder = searchPlaceholder ?? t('ui.select.search')
@@ -58,7 +151,6 @@ export function Combobox({
58
151
 
59
152
  React.useEffect(() => {
60
153
  if (scrollRef.current && open) {
61
- // Force scrollable styles with !important
62
154
  const el = scrollRef.current
63
155
  el.style.cssText = `
64
156
  max-height: 300px !important;
@@ -78,12 +170,10 @@ export function Combobox({
78
170
  const filteredOptions = React.useMemo(() => {
79
171
  if (!search) return options
80
172
 
81
- // Use custom filter function if provided
82
173
  if (filterFunction) {
83
174
  return options.filter((option) => filterFunction(option, search))
84
175
  }
85
176
 
86
- // Default filtering: simple includes
87
177
  const searchLower = search.toLowerCase()
88
178
  return options.filter(
89
179
  (option) =>
@@ -93,6 +183,17 @@ export function Combobox({
93
183
  )
94
184
  }, [options, search, filterFunction])
95
185
 
186
+ const handleSelect = React.useCallback(
187
+ (currentValue: string) => {
188
+ const next = currentValue === value ? "" : currentValue;
189
+ if (storageKey) setStoredValue(next);
190
+ if (controlledValue === undefined) setInternalValue(next);
191
+ onValueChange?.(next);
192
+ setOpen(false);
193
+ },
194
+ [value, storageKey, setStoredValue, controlledValue, onValueChange],
195
+ );
196
+
96
197
  return (
97
198
  <Popover
98
199
  open={open}
@@ -154,8 +255,7 @@ export function Combobox({
154
255
  value={option.value}
155
256
  onSelect={(currentValue) => {
156
257
  if (!option.disabled) {
157
- onValueChange?.(currentValue === value ? "" : currentValue)
158
- setOpen(false)
258
+ handleSelect(currentValue)
159
259
  }
160
260
  }}
161
261
  disabled={option.disabled}
@@ -1,9 +1,83 @@
1
1
  import * as React from 'react';
2
2
 
3
+ import { useStoredValue, type StorageType, type UseStoredValueOptions } from '../hooks/useStoredValue';
3
4
  import { cn } from '../lib/utils';
4
5
 
5
- const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
6
- ({ className, type, ...props }, ref) => {
6
+ export interface InputProps extends React.ComponentProps<"input"> {
7
+ /**
8
+ * When provided, the input value is persisted to storage under this key.
9
+ *
10
+ * Rules:
11
+ * - If the parent passes `value` (controlled), storage is used for initial
12
+ * hydration only on first mount (defaultValue path is not applicable), but
13
+ * writes are still saved so the next mount can restore the value.
14
+ * In fully controlled mode the parent owns the value — storage just seeds
15
+ * the `defaultValue` on next fresh mount if the parent doesn't supply one.
16
+ * - If the parent passes `defaultValue` or neither, the component is
17
+ * uncontrolled — storage provides the default and tracks changes via
18
+ * an onChange wrapper.
19
+ *
20
+ * @example storageKey="search-filter"
21
+ */
22
+ storageKey?: string;
23
+ /**
24
+ * Which storage backend to use.
25
+ * @default 'local'
26
+ */
27
+ storageType?: StorageType;
28
+ /**
29
+ * TTL in ms. Stored value expires after this duration.
30
+ */
31
+ storageTtl?: number;
32
+ }
33
+
34
+ const Input = React.forwardRef<HTMLInputElement, InputProps>(
35
+ ({ className, type, storageKey, storageType, storageTtl, onChange, defaultValue, value, ...props }, ref) => {
36
+ const storageOptions: UseStoredValueOptions | undefined =
37
+ storageKey ? { storage: storageType ?? 'local', ttl: storageTtl } : undefined;
38
+
39
+ const [storedValue, setStoredValue] = useStoredValue<string>(
40
+ storageKey,
41
+ // seed: use provided defaultValue or controlled value as fallback seed
42
+ (defaultValue as string | undefined) ?? (value as string | undefined) ?? '',
43
+ storageOptions,
44
+ );
45
+
46
+ // Wrap onChange to persist to storage on every change.
47
+ // Only active when storageKey is provided.
48
+ const handleChange = React.useCallback(
49
+ (e: React.ChangeEvent<HTMLInputElement>) => {
50
+ if (storageKey) {
51
+ setStoredValue(e.target.value);
52
+ }
53
+ onChange?.(e);
54
+ },
55
+ [storageKey, setStoredValue, onChange],
56
+ );
57
+
58
+ // If parent passes explicit `value` — fully controlled, we don't inject
59
+ // defaultValue (React would warn about switching modes). We still write
60
+ // to storage on change via handleChange.
61
+ if (value !== undefined) {
62
+ return (
63
+ <input
64
+ type={type}
65
+ className={cn(
66
+ "flex h-10 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
67
+ className
68
+ )}
69
+ ref={ref}
70
+ value={value}
71
+ onChange={storageKey ? handleChange : onChange}
72
+ {...props}
73
+ />
74
+ );
75
+ }
76
+
77
+ // Uncontrolled: inject stored value as defaultValue (if no defaultValue given).
78
+ // React requires defaultValue to be stable on first render — storedValue from
79
+ // useLocalStorage is initialValue on SSR and the real value after hydration.
80
+ // This is fine: defaultValue only seeds the initial DOM value.
7
81
  return (
8
82
  <input
9
83
  type={type}
@@ -12,11 +86,13 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
12
86
  className
13
87
  )}
14
88
  ref={ref}
89
+ defaultValue={storageKey && storedValue ? storedValue : defaultValue}
90
+ onChange={storageKey ? handleChange : onChange}
15
91
  {...props}
16
92
  />
17
- )
18
- }
19
- )
93
+ );
94
+ },
95
+ );
20
96
  Input.displayName = "Input"
21
97
 
22
98
  export { Input }
@@ -4,6 +4,7 @@ import { Check, ChevronsUpDown, X } from 'lucide-react';
4
4
  import * as React from 'react';
5
5
 
6
6
  import { useTypedT, type I18nTranslations } from '@djangocfg/i18n';
7
+ import { useStoredValue, type StorageType, type UseStoredValueOptions } from '../hooks/useStoredValue';
7
8
  import { cn } from '../lib/utils';
8
9
  import { Badge } from './badge';
9
10
  import { Button } from './button';
@@ -29,11 +30,39 @@ export interface MultiSelectProps {
29
30
  className?: string
30
31
  disabled?: boolean
31
32
  maxDisplay?: number
33
+ /**
34
+ * When provided, the selected values array is persisted under this key.
35
+ *
36
+ * On mount: stored values that are present in the current options are
37
+ * restored. Values that no longer exist in options are silently dropped
38
+ * (safe against stale data from removed options).
39
+ *
40
+ * @example storageKey="selected-tags"
41
+ */
42
+ storageKey?: string
43
+ /** @default 'local' */
44
+ storageType?: StorageType
45
+ /** TTL in ms */
46
+ storageTtl?: number
47
+ /**
48
+ * When true AND storageKey is set: after options load/change, the stored
49
+ * selection is filtered to only include values present in the current
50
+ * options. Values not in options are removed from the selection and
51
+ * from storage.
52
+ *
53
+ * When false (default): stored values are only used to seed the initial
54
+ * selection. Subsequent option changes don't re-filter.
55
+ *
56
+ * @default false
57
+ */
58
+ autoSelectFromOptions?: boolean
32
59
  }
33
60
 
61
+ const EMPTY_ARRAY: string[] = [];
62
+
34
63
  export function MultiSelect({
35
64
  options,
36
- value = [],
65
+ value: controlledValue,
37
66
  onChange,
38
67
  placeholder,
39
68
  searchPlaceholder,
@@ -41,17 +70,73 @@ export function MultiSelect({
41
70
  className,
42
71
  disabled = false,
43
72
  maxDisplay = 3,
73
+ storageKey,
74
+ storageType,
75
+ storageTtl,
76
+ autoSelectFromOptions = false,
44
77
  }: MultiSelectProps) {
45
78
  const t = useTypedT<I18nTranslations>()
46
79
  const [open, setOpen] = React.useState(false)
47
80
  const [search, setSearch] = React.useState("")
48
81
  const scrollRef = React.useRef<HTMLDivElement>(null)
49
82
 
83
+ const storageOptions: UseStoredValueOptions | undefined =
84
+ storageKey ? { storage: storageType ?? 'local', ttl: storageTtl } : undefined;
85
+
86
+ const [storedValue, setStoredValue] = useStoredValue<string[]>(
87
+ storageKey,
88
+ controlledValue ?? EMPTY_ARRAY,
89
+ storageOptions,
90
+ );
91
+
92
+ // Internal selection state for uncontrolled mode
93
+ const [internalValue, setInternalValue] = React.useState<string[]>(
94
+ controlledValue !== undefined ? controlledValue : storedValue,
95
+ );
96
+
97
+ const value = controlledValue !== undefined ? controlledValue : internalValue;
98
+
50
99
  // Resolve translations
51
100
  const resolvedPlaceholder = placeholder ?? t('ui.select.placeholder')
52
101
  const resolvedSearchPlaceholder = searchPlaceholder ?? t('ui.select.search')
53
102
  const resolvedEmptyText = emptyText ?? t('ui.select.noResults')
54
103
 
104
+ // autoSelectFromOptions: filter stored selection to valid options only
105
+ const autoSelectApplied = React.useRef(false);
106
+
107
+ React.useEffect(() => {
108
+ if (!storageKey || !autoSelectFromOptions || options.length === 0) return;
109
+ if (autoSelectApplied.current) return;
110
+ autoSelectApplied.current = true;
111
+
112
+ const saved = storedValue;
113
+ if (!saved || saved.length === 0) return;
114
+
115
+ const validOptionValues = new Set(
116
+ options.filter((o) => !o.disabled).map((o) => o.value)
117
+ );
118
+ const filtered = saved.filter((v) => validOptionValues.has(v));
119
+
120
+ // Only update if something actually changed
121
+ const changed = filtered.length !== saved.length ||
122
+ filtered.some((v, i) => v !== saved[i]);
123
+
124
+ if (changed) {
125
+ setStoredValue(filtered);
126
+ if (controlledValue === undefined) setInternalValue(filtered);
127
+ onChange?.(filtered);
128
+ } else if (filtered.length > 0) {
129
+ // Restore valid selection to parent
130
+ if (controlledValue === undefined) setInternalValue(filtered);
131
+ onChange?.(filtered);
132
+ }
133
+ // eslint-disable-next-line react-hooks/exhaustive-deps
134
+ }, [options, storageKey, autoSelectFromOptions]);
135
+
136
+ React.useEffect(() => {
137
+ autoSelectApplied.current = false;
138
+ }, [storageKey]);
139
+
55
140
  React.useEffect(() => {
56
141
  if (scrollRef.current && open) {
57
142
  const el = scrollRef.current
@@ -81,17 +166,22 @@ export function MultiSelect({
81
166
  )
82
167
  }, [options, search])
83
168
 
84
- const handleSelect = (optionValue: string) => {
169
+ const handleSelect = React.useCallback((optionValue: string) => {
85
170
  const newValue = value.includes(optionValue)
86
171
  ? value.filter((v) => v !== optionValue)
87
- : [...value, optionValue]
88
- onChange?.(newValue)
89
- }
172
+ : [...value, optionValue];
173
+ if (storageKey) setStoredValue(newValue);
174
+ if (controlledValue === undefined) setInternalValue(newValue);
175
+ onChange?.(newValue);
176
+ }, [value, storageKey, setStoredValue, controlledValue, onChange]);
90
177
 
91
- const handleRemove = (optionValue: string, e: React.MouseEvent) => {
92
- e.stopPropagation()
93
- onChange?.(value.filter((v) => v !== optionValue))
94
- }
178
+ const handleRemove = React.useCallback((optionValue: string, e: React.MouseEvent) => {
179
+ e.stopPropagation();
180
+ const newValue = value.filter((v) => v !== optionValue);
181
+ if (storageKey) setStoredValue(newValue);
182
+ if (controlledValue === undefined) setInternalValue(newValue);
183
+ onChange?.(newValue);
184
+ }, [value, storageKey, setStoredValue, controlledValue, onChange]);
95
185
 
96
186
  const displayValue = React.useMemo(() => {
97
187
  if (selectedOptions.length === 0) {
@@ -127,7 +217,7 @@ export function MultiSelect({
127
217
  )}
128
218
  </div>
129
219
  )
130
- }, [selectedOptions, maxDisplay, resolvedPlaceholder, disabled, t])
220
+ }, [selectedOptions, maxDisplay, resolvedPlaceholder, disabled, t, handleRemove])
131
221
 
132
222
  return (
133
223
  <Popover
@@ -4,13 +4,54 @@ import * as React from 'react';
4
4
 
5
5
  import * as SliderPrimitive from '@radix-ui/react-slider';
6
6
 
7
+ import { useStoredValue, type StorageType, type UseStoredValueOptions } from '../hooks/useStoredValue';
7
8
  import { cn } from '../lib/utils';
8
9
 
10
+ export interface SliderProps extends React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> {
11
+ /**
12
+ * When provided, the slider value is persisted under this key.
13
+ * Works with both controlled (`value`) and uncontrolled (`defaultValue`) modes.
14
+ * @example storageKey="volume-level"
15
+ */
16
+ storageKey?: string
17
+ /** @default 'local' */
18
+ storageType?: StorageType
19
+ /** TTL in ms */
20
+ storageTtl?: number
21
+ }
22
+
9
23
  const Slider = React.forwardRef<
10
24
  React.ElementRef<typeof SliderPrimitive.Root>,
11
- React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
12
- >(({ className, orientation = 'horizontal', ...props }, ref) => {
25
+ SliderProps
26
+ >(({ className, orientation = 'horizontal', storageKey, storageType, storageTtl, onValueChange, ...props }, ref) => {
13
27
  const isVertical = orientation === 'vertical';
28
+
29
+ const storageOptions: UseStoredValueOptions | undefined =
30
+ storageKey ? { storage: storageType ?? 'local', ttl: storageTtl } : undefined;
31
+
32
+ const [storedValue, setStoredValue] = useStoredValue<number[]>(
33
+ storageKey,
34
+ (props.defaultValue as number[] | undefined) ?? (props.value as number[] | undefined) ?? [],
35
+ storageOptions,
36
+ );
37
+
38
+ const handleValueChange = React.useCallback(
39
+ (newValue: number[]) => {
40
+ if (storageKey) setStoredValue(newValue);
41
+ onValueChange?.(newValue);
42
+ },
43
+ [storageKey, setStoredValue, onValueChange],
44
+ );
45
+
46
+ const sliderProps = {
47
+ ...props,
48
+ onValueChange: storageKey ? handleValueChange : onValueChange,
49
+ // Inject stored defaultValue only in uncontrolled mode
50
+ ...(storageKey && props.value === undefined && storedValue.length > 0
51
+ ? { defaultValue: storedValue }
52
+ : {}),
53
+ };
54
+
14
55
  return (
15
56
  <SliderPrimitive.Root
16
57
  ref={ref}
@@ -22,7 +63,7 @@ const Slider = React.forwardRef<
22
63
  : "flex-row items-center w-full",
23
64
  className
24
65
  )}
25
- {...props}
66
+ {...sliderProps}
26
67
  >
27
68
  <SliderPrimitive.Track
28
69
  className={cn(
@@ -6,6 +6,7 @@ import * as React from 'react';
6
6
  import * as TabsPrimitive from '@radix-ui/react-tabs';
7
7
 
8
8
  import { useIsMobile } from '../hooks';
9
+ import { useStoredValue, type StorageType } from '../hooks/useStoredValue';
9
10
  import { useTypedT, type I18nTranslations } from '@djangocfg/i18n';
10
11
  import { cn } from '../lib/utils';
11
12
  import { Button } from './button';
@@ -36,17 +37,54 @@ export interface TabsProps extends React.ComponentPropsWithoutRef<typeof TabsPri
36
37
  * @default false
37
38
  */
38
39
  sticky?: boolean
40
+ /**
41
+ * When provided, the active tab value is persisted under this key.
42
+ * On mount the last active tab is restored automatically.
43
+ * Works with both controlled (`value`) and uncontrolled (`defaultValue`) modes.
44
+ * In controlled mode the stored value is passed to `defaultValue` on first
45
+ * render; the parent stays in charge after that.
46
+ * @example storageKey="admin-tab"
47
+ */
48
+ storageKey?: string
49
+ /** @default 'local' */
50
+ storageType?: StorageType
39
51
  }
40
52
 
41
53
  const Tabs = React.forwardRef<
42
54
  React.ElementRef<typeof TabsPrimitive.Root>,
43
55
  TabsProps
44
- >(({ mobileSheet = false, mobileSheetTitle, mobileTitleText, sticky = false, children, ...props }, ref) => {
56
+ >(({ mobileSheet = false, mobileSheetTitle, mobileTitleText, sticky = false, storageKey, storageType, children, ...props }, ref) => {
45
57
  const t = useTypedT<I18nTranslations>()
46
58
  const resolvedMobileSheetTitle = mobileSheetTitle ?? t('ui.navigation.title')
47
59
  const isMobile = useIsMobile()
48
60
  const [open, setOpen] = React.useState(false)
49
61
 
62
+ const [storedTab, setStoredTab] = useStoredValue<string>(
63
+ storageKey,
64
+ // seed: prefer defaultValue prop, then controlled value, then empty
65
+ (props.defaultValue as string | undefined) ?? (props.value as string | undefined) ?? '',
66
+ storageKey ? { storage: storageType ?? 'local' } : undefined,
67
+ );
68
+
69
+ // Wrap onValueChange to persist tab changes
70
+ const handleValueChange = React.useCallback(
71
+ (newValue: string) => {
72
+ if (storageKey) setStoredTab(newValue);
73
+ props.onValueChange?.(newValue);
74
+ },
75
+ [storageKey, setStoredTab, props.onValueChange],
76
+ );
77
+
78
+ // Build enhanced props: inject stored defaultValue when no value/defaultValue given
79
+ const tabsProps = {
80
+ ...props,
81
+ onValueChange: storageKey ? handleValueChange : props.onValueChange,
82
+ // Only inject defaultValue when: storageKey set, no controlled value, storedTab non-empty
83
+ ...(storageKey && props.value === undefined && storedTab
84
+ ? { defaultValue: storedTab }
85
+ : {}),
86
+ };
87
+
50
88
  // If mobile sheet mode is disabled, render normal tabs
51
89
  if (!mobileSheet || !isMobile) {
52
90
  // If sticky is enabled for desktop, wrap TabsList in sticky container
@@ -60,7 +98,7 @@ const Tabs = React.forwardRef<
60
98
  )
61
99
 
62
100
  return (
63
- <TabsPrimitive.Root ref={ref} {...props}>
101
+ <TabsPrimitive.Root ref={ref} {...tabsProps}>
64
102
  <div className="sticky top-0 z-40 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 mb-4">
65
103
  {tabsList}
66
104
  </div>
@@ -70,7 +108,7 @@ const Tabs = React.forwardRef<
70
108
  }
71
109
 
72
110
  return (
73
- <TabsPrimitive.Root ref={ref} {...props}>
111
+ <TabsPrimitive.Root ref={ref} {...tabsProps}>
74
112
  {children}
75
113
  </TabsPrimitive.Root>
76
114
  )
@@ -92,7 +130,7 @@ const Tabs = React.forwardRef<
92
130
 
93
131
  // Mobile Sheet Navigation
94
132
  return (
95
- <TabsPrimitive.Root ref={ref} {...props}>
133
+ <TabsPrimitive.Root ref={ref} {...tabsProps}>
96
134
  <div className={cn(
97
135
  "w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 mb-4",
98
136
  sticky && "sticky top-0 z-40"
@@ -1,22 +1,71 @@
1
1
  import * as React from 'react';
2
2
 
3
+ import { useStoredValue, type StorageType, type UseStoredValueOptions } from '../hooks/useStoredValue';
3
4
  import { cn } from '../lib/utils';
4
5
 
5
- const Textarea = React.forwardRef<
6
- HTMLTextAreaElement,
7
- React.ComponentProps<"textarea">
8
- >(({ className, ...props }, ref) => {
9
- return (
10
- <textarea
11
- className={cn(
12
- "flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
13
- className
14
- )}
15
- ref={ref}
16
- {...props}
17
- />
18
- )
19
- })
6
+ export interface TextareaProps extends React.ComponentProps<"textarea"> {
7
+ /**
8
+ * When provided, the textarea value is persisted to storage under this key.
9
+ * Same controlled/uncontrolled rules as Input.
10
+ * @example storageKey="draft-message"
11
+ */
12
+ storageKey?: string;
13
+ /** @default 'local' */
14
+ storageType?: StorageType;
15
+ /** TTL in ms */
16
+ storageTtl?: number;
17
+ }
18
+
19
+ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
20
+ ({ className, storageKey, storageType, storageTtl, onChange, defaultValue, value, ...props }, ref) => {
21
+ const storageOptions: UseStoredValueOptions | undefined =
22
+ storageKey ? { storage: storageType ?? 'local', ttl: storageTtl } : undefined;
23
+
24
+ const [storedValue, setStoredValue] = useStoredValue<string>(
25
+ storageKey,
26
+ (defaultValue as string | undefined) ?? (value as string | undefined) ?? '',
27
+ storageOptions,
28
+ );
29
+
30
+ const handleChange = React.useCallback(
31
+ (e: React.ChangeEvent<HTMLTextAreaElement>) => {
32
+ if (storageKey) {
33
+ setStoredValue(e.target.value);
34
+ }
35
+ onChange?.(e);
36
+ },
37
+ [storageKey, setStoredValue, onChange],
38
+ );
39
+
40
+ if (value !== undefined) {
41
+ return (
42
+ <textarea
43
+ className={cn(
44
+ "flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
45
+ className
46
+ )}
47
+ ref={ref}
48
+ value={value}
49
+ onChange={storageKey ? handleChange : onChange}
50
+ {...props}
51
+ />
52
+ );
53
+ }
54
+
55
+ return (
56
+ <textarea
57
+ className={cn(
58
+ "flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
59
+ className
60
+ )}
61
+ ref={ref}
62
+ defaultValue={storageKey && storedValue ? storedValue : defaultValue}
63
+ onChange={storageKey ? handleChange : onChange}
64
+ {...props}
65
+ />
66
+ );
67
+ },
68
+ );
20
69
  Textarea.displayName = "Textarea"
21
70
 
22
71
  export { Textarea }
@@ -19,6 +19,8 @@ export { useResolvedTheme } from './useResolvedTheme';
19
19
  export type { ResolvedTheme } from './useResolvedTheme';
20
20
  export { useLocalStorage } from './useLocalStorage';
21
21
  export { useSessionStorage } from './useSessionStorage';
22
+ export { useStoredValue } from './useStoredValue';
23
+ export type { UseStoredValueOptions, StorageType } from './useStoredValue';
22
24
  export { useHotkey, useHotkeysContext, HotkeysProvider, isHotkeyPressed } from './useHotkey';
23
25
  export type { UseHotkeyOptions, HotkeyCallback, Keys, HotkeyRefType } from './useHotkey';
24
26
  export { useBrowserDetect } from './useBrowserDetect';
@@ -0,0 +1,72 @@
1
+ 'use client';
2
+
3
+ import { useCallback } from 'react';
4
+
5
+ import { useLocalStorage } from './useLocalStorage';
6
+ import { useSessionStorage } from './useSessionStorage';
7
+
8
+ export type StorageType = 'local' | 'session';
9
+
10
+ export interface UseStoredValueOptions {
11
+ /**
12
+ * 'local' — persists across browser sessions (localStorage)
13
+ * 'session' — cleared when tab is closed (sessionStorage)
14
+ * @default 'local'
15
+ */
16
+ storage?: StorageType;
17
+ /**
18
+ * Time-to-live in milliseconds. After expiry, initialValue is returned.
19
+ * @example ttl: 24 * 60 * 60 * 1000 // 24 hours
20
+ */
21
+ ttl?: number;
22
+ }
23
+
24
+ /**
25
+ * Unified storage hook that delegates to useLocalStorage or useSessionStorage.
26
+ *
27
+ * Used by components (Input, Combobox, MultiSelect, Tabs, etc.) to add
28
+ * optional persistence via a single `storageKey` prop.
29
+ *
30
+ * When storageKey is undefined the hook is a no-op:
31
+ * - returns initialValue as current value
32
+ * - setValue / removeValue are no-ops
33
+ * This means adding storageKey to a component has zero overhead when unused.
34
+ *
35
+ * @example
36
+ * const [value, setValue, removeValue] = useStoredValue('my-key', '', { storage: 'session' });
37
+ */
38
+ export function useStoredValue<T>(
39
+ storageKey: string | undefined,
40
+ initialValue: T,
41
+ options?: UseStoredValueOptions,
42
+ ): readonly [T, (value: T | ((prev: T) => T)) => void, () => void] {
43
+ const storageType = options?.storage ?? 'local';
44
+ const ttlOption = options?.ttl ? { ttl: options.ttl } : undefined;
45
+
46
+ // Both hooks are always called (rules of hooks — no conditional calls).
47
+ // Only one of them is actually used depending on storageType.
48
+ // When storageKey is undefined we pass a dummy key; the result is discarded.
49
+ const dummyKey = '__useStoredValue_noop__';
50
+
51
+ const localResult = useLocalStorage<T>(
52
+ storageType === 'local' && storageKey ? storageKey : dummyKey,
53
+ initialValue,
54
+ ttlOption,
55
+ );
56
+
57
+ const sessionResult = useSessionStorage<T>(
58
+ storageType === 'session' && storageKey ? storageKey : dummyKey,
59
+ initialValue,
60
+ ttlOption,
61
+ );
62
+
63
+ const noopSetValue = useCallback((_value: T | ((prev: T) => T)) => {}, []);
64
+ const noopRemove = useCallback(() => {}, []);
65
+
66
+ // No storageKey — pure no-op, no storage reads/writes
67
+ if (!storageKey) {
68
+ return [initialValue, noopSetValue, noopRemove] as const;
69
+ }
70
+
71
+ return storageType === 'local' ? localResult : sessionResult;
72
+ }
@@ -7,4 +7,4 @@ export {
7
7
  createLazyNamedComponent,
8
8
  } from './LazyComponent';
9
9
  export type { LazyWrapperProps } from './LazyComponent';
10
- ../utils/runtime-errors
10
+ export { emitRuntimeError, RUNTIME_ERROR_EVENT } from '../utils/runtime-errors';