@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 +4 -4
- package/src/components/accordion.tsx +82 -1
- package/src/components/combobox.tsx +106 -6
- package/src/components/input.tsx +81 -5
- package/src/components/multi-select.tsx +100 -10
- package/src/components/slider.tsx +44 -3
- package/src/components/tabs.tsx +42 -4
- package/src/components/textarea.tsx +64 -15
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useStoredValue.ts +72 -0
- package/src/snippets/index.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-core",
|
|
3
|
-
"version": "2.1.
|
|
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.
|
|
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.
|
|
146
|
+
"@djangocfg/i18n": "^2.1.227",
|
|
147
147
|
"@djangocfg/playground": "workspace:*",
|
|
148
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
setOpen(false)
|
|
258
|
+
handleSelect(currentValue)
|
|
159
259
|
}
|
|
160
260
|
}}
|
|
161
261
|
disabled={option.disabled}
|
package/src/components/input.tsx
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{...
|
|
66
|
+
{...sliderProps}
|
|
26
67
|
>
|
|
27
68
|
<SliderPrimitive.Track
|
|
28
69
|
className={cn(
|
package/src/components/tabs.tsx
CHANGED
|
@@ -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} {...
|
|
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} {...
|
|
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} {...
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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 }
|
package/src/hooks/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/snippets/index.ts
CHANGED