@djangocfg/ui-core 2.1.602 → 2.1.604

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/README.md CHANGED
@@ -51,7 +51,7 @@ import { UiProviders, Button, Card } from '@djangocfg/ui-core';
51
51
  | Group | Examples |
52
52
  |---|---|
53
53
  | `components/data/` | Avatar · AvatarGroup · Badge · BadgeOverflow · Card · Table · BalancedText · Skeleton · Stat · Status · Progress · CircularProgress · Carousel · Calendar · Toggle · ToggleGroup · RelativeTimeCard (`timeZone` prop) · Chart (subpath `/chart`) |
54
- | `components/forms/` | Button · DownloadButton · ButtonGroup · Input · InputGroup · InputAffix · InputOTP · OTPInput · PhoneInput · MaskInput · SegmentedInput · TagsInput · Textarea · Switch · Checkbox · CheckboxGroup · RadioGroup · Slider · Form · Field · Editable · SettingRow / SettingsBlock · DateField · TimeField · DateTimeField · MoneyField · FilterButton · FilterMenu · PopoverActionButton · TimePicker (legacy) |
54
+ | `components/forms/` | Button · CONTROL_SIZE · DownloadButton · ButtonGroup · Input · InputGroup · InputAffix · InputOTP · OTPInput · PhoneInput · MaskInput · SegmentedInput · TagsInput · Textarea · Switch · Checkbox · CheckboxGroup · RadioGroup · Slider · Form · Field · Editable · SettingRow / SettingsBlock · DateField · TimeField · DateTimeField · MoneyField · FilterButton · FilterMenu · PopoverActionButton · TimePicker (legacy) |
55
55
  | `components/select/` | Select · Combobox · ComboboxAsync · MultiSelect · MultiSelectPro · CountrySelect · LanguageSelect · TimezoneSelect |
56
56
  | `components/feedback/` | Alert · Toast (sonner) · Banner · Empty · Preloader · Spinner |
57
57
  | `components/overlay/` | Dialog · AlertDialog · Drawer · Sheet · ResponsiveSheet · SidePanel · Popover · Tooltip · HoverCard |
@@ -61,6 +61,29 @@ import { UiProviders, Button, Card } from '@djangocfg/ui-core';
61
61
  | `components/specialized/` | CopyButton · CopyField · Kbd · Flag · ImageWithFallback · Item · TokenIcon · Portal · Presence · Primitive · VisuallyHidden |
62
62
  | `components/boundary/` | Boundary (error boundary, mounted by `UiProviders`) |
63
63
 
64
+ ### Control sizes
65
+
66
+ `Button` and every field-like control read one height ladder,
67
+ `CONTROL_SIZE` (`forms/control-size.ts`), so a row of them lines up at every
68
+ size. Never write `h-9` / `h-10` on a control: read the map, or pass the size.
69
+
70
+ | Size | Height | Button `size` | `Input` / `InputGroup` / `DateField` `inputSize` | `SelectTrigger` `size` |
71
+ |---|---|---|---|---|
72
+ | `xs` | 28px (`h-7`) | yes | yes | yes |
73
+ | `sm` | 32px (`h-8`) | yes | yes | yes |
74
+ | `default` | 36px (`h-9`) | yes | yes | yes |
75
+ | `lg` | 44px (`h-11`) | yes | yes | yes |
76
+ | `huge` | 56px (`h-14`) | yes | no | no |
77
+
78
+ `Button` also has `icon` (36px square). Triggers built on `Button`
79
+ (`Combobox`, `LanguageSelect`, `DatePicker`) take their height from the Button
80
+ size; the multi-select triggers use `minHeight` so chips can wrap but never
81
+ sit under the fixed control. Not on the ladder, on purpose: `FilterButton`
82
+ (its `FilterBar` sets the height), OTP / segmented cells (square code cells),
83
+ and `Textarea` (multi-line).
84
+ `control-size.test.tsx` fails if `Button`, `Input` or `SelectTrigger` stop
85
+ agreeing; the Storybook story `Controls / Alignment` draws the guide lines.
86
+
64
87
  ### MenuBuilder
65
88
 
66
89
  Declarative menus: pass `MenuItem[]` and a trigger, it renders through the
@@ -94,6 +117,39 @@ fields (`DateField` / `TimeField` / `DateTimeField`) have their own reference in
94
117
  | `time/` | `useNow` · `useInterval` · `useTimeout` |
95
118
  | `router/` | `useLink` · `useRouter` (router-adapter consumer) |
96
119
 
120
+ ### Storage keys and namespaces (`hooks/state`)
121
+
122
+ `useLocalStorage`, `useSessionStorage` and `useStoredValue` (every component
123
+ `storageKey` prop) write under one scheme, `<owner>.<area>.<name>`, e.g.
124
+ `cfg.ui.sidebar_width`, `kino.player.volume`. Full rules for agents:
125
+ `@djangocfg/api` → `@docs/STORAGE_KEYS.md`.
126
+
127
+ - A key that already follows the scheme is used as is.
128
+ - A bare name (`'search-filter'`) is folded to `snake_case` and put under the
129
+ active owner: `cfg.ui.search_filter`. The owner is the hook's `namespace`
130
+ option, else the nearest `<StorageNamespaceProvider owner="kino">`, else the
131
+ framework default `cfg`.
132
+ - Quota recovery drops only non-personal keys of our scheme, never tokens,
133
+ drafts, the profile or third-party keys (`NEXT_LOCALE`).
134
+
135
+ ```tsx
136
+ // app root, once
137
+ <StorageNamespaceProvider owner="kino">…</StorageNamespaceProvider>
138
+
139
+ // lib/storage.ts, fixed keys declared once
140
+ export const kino = createStorageNamespace('kino');
141
+ export const VOLUME = kino.key('player', 'volume'); // kino.player.volume
142
+
143
+ const [volume, setVolume] = useLocalStorage(VOLUME, 1);
144
+ kino.keys(); // every kino.* key in localStorage
145
+ kino.clear(); // "reset preferences" (personal cfg keys survive)
146
+ ```
147
+
148
+ Helpers: `resolveStorageKey`, `isSchemeKey`, `listStorageKeys(owner, area)`,
149
+ `clearStorageNamespace(owner, { area, includePersonal })`,
150
+ `createStorageNamespace(owner)` (`key`, `storage` = guarded read/write/onChange, `keys`, `clear`).
151
+ Do not write `localStorage` literals or ad-hoc keys in components.
152
+
97
153
  ## Lib utilities (`/lib`)
98
154
 
99
155
  - `cn(...)` — `clsx` + `tailwind-merge` shortcut
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-core",
3
- "version": "2.1.602",
3
+ "version": "2.1.604",
4
4
  "description": "Pure React UI component library without Next.js dependencies - for Electron, Vite, CRA apps",
5
5
  "keywords": [
6
6
  "ui-components",
@@ -120,19 +120,21 @@
120
120
  "dist",
121
121
  "src",
122
122
  "!src/**/*.stories.tsx",
123
+ "!src/**/*.test.{ts,tsx}",
123
124
  "!src/components/_gallery.tsx",
124
125
  "README.md",
125
126
  "LICENSE"
126
127
  ],
127
128
  "scripts": {
128
129
  "lint": "eslint .",
130
+ "test": "vitest run",
129
131
  "check": "tsc --noEmit && node scripts/check-preset-contrast.mjs && node scripts/check-docs.mjs",
130
132
  "check:contrast": "node scripts/check-preset-contrast.mjs",
131
133
  "check:docs": "node scripts/check-docs.mjs"
132
134
  },
133
135
  "peerDependencies": {
134
- "@djangocfg/api": "^2.1.602",
135
- "@djangocfg/i18n": "^2.1.602",
136
+ "@djangocfg/api": "^2.1.604",
137
+ "@djangocfg/i18n": "^2.1.604",
136
138
  "consola": "^3.4.2",
137
139
  "lucide-react": "^0.545.0",
138
140
  "moment": "^2.30.1",
@@ -208,10 +210,10 @@
208
210
  "vaul": "1.1.2"
209
211
  },
210
212
  "devDependencies": {
211
- "@djangocfg/api": "^2.1.602",
212
- "@djangocfg/eslint-config": "^2.1.602",
213
- "@djangocfg/i18n": "^2.1.602",
214
- "@djangocfg/typescript-config": "^2.1.602",
213
+ "@djangocfg/api": "^2.1.604",
214
+ "@djangocfg/eslint-config": "^2.1.604",
215
+ "@djangocfg/i18n": "^2.1.604",
216
+ "@djangocfg/typescript-config": "^2.1.604",
215
217
  "@storybook/react-vite": "^10.5.0",
216
218
  "@types/node": "^24.13.3",
217
219
  "@types/react": "19.2.15",
@@ -219,7 +221,8 @@
219
221
  "lucide-react": "^0.545.0",
220
222
  "next": "^16.2.10",
221
223
  "storybook": "^10.5.0",
222
- "typescript": "^5.9.3"
224
+ "typescript": "^5.9.3",
225
+ "vitest": "^4.1.10"
223
226
  },
224
227
  "publishConfig": {
225
228
  "access": "public"
@@ -6,6 +6,7 @@ import * as React from 'react';
6
6
  import * as TogglePrimitive from '@radix-ui/react-toggle';
7
7
 
8
8
  import { cn } from '../../../lib/utils';
9
+ import { CONTROL_SIZE } from '../../forms/control-size';
9
10
 
10
11
  const toggleVariants = cva(
11
12
  "inline-flex items-center justify-center gap-2 rounded-[var(--radius)] text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
@@ -17,9 +18,9 @@ const toggleVariants = cva(
17
18
  "border border-border bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
18
19
  },
19
20
  size: {
20
- default: "h-9 px-2 min-w-9",
21
- sm: "h-8 px-1.5 min-w-8",
22
- lg: "h-10 px-2.5 min-w-10",
21
+ default: cn(CONTROL_SIZE.default.height, "px-2 min-w-9"),
22
+ sm: cn(CONTROL_SIZE.sm.height, "px-1.5 min-w-8"),
23
+ lg: cn(CONTROL_SIZE.lg.height, "px-2.5 min-w-11"),
23
24
  },
24
25
  },
25
26
  defaultVariants: {
@@ -8,6 +8,7 @@ import { Slot } from '@radix-ui/react-slot';
8
8
 
9
9
  import { cn } from '../../../lib/utils';
10
10
  import { Link } from '../../navigation/link';
11
+ import { buttonSizeClass, CONTROL_SIZE } from '../control-size';
11
12
 
12
13
  const buttonVariants = cva(
13
14
  "inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-[var(--radius)] text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
@@ -30,13 +31,16 @@ const buttonVariants = cva(
30
31
  ghost: "hover:bg-accent hover:text-accent-foreground",
31
32
  link: "text-primary underline-offset-4 hover:underline",
32
33
  },
34
+ // Height comes from the shared control ladder, so a Button at any size
35
+ // lines up with the Input / Select trigger of the same size. Only the
36
+ // corner radius is decided here.
33
37
  size: {
34
- default: "h-9 px-4 py-2",
35
- sm: "h-8 rounded-[var(--radius)] px-3 text-sm",
36
- xs: "h-7 rounded-[var(--radius-sm)] px-2 py-1 text-xs",
37
- lg: "h-11 rounded-[var(--radius)] px-8",
38
- huge: "h-14 rounded-[var(--radius)] px-10 text-lg",
39
- icon: "h-9 w-9",
38
+ default: buttonSizeClass('default'),
39
+ sm: cn(buttonSizeClass('sm'), "rounded-[var(--radius)]"),
40
+ xs: cn(buttonSizeClass('xs'), "rounded-[var(--radius-sm)]"),
41
+ lg: cn(buttonSizeClass('lg'), "rounded-[var(--radius)]"),
42
+ huge: cn(buttonSizeClass('huge'), "rounded-[var(--radius)]"),
43
+ icon: cn(CONTROL_SIZE.default.height, "w-9"),
40
44
  },
41
45
  // Most variants ship a baked-in `shadow`/`shadow-sm` for a lifted look.
42
46
  // `elevated={false}` flattens that (e.g. controls grouped inside a card —
@@ -0,0 +1,67 @@
1
+ import { cn } from '../../lib/utils';
2
+
3
+ /**
4
+ * The one height ladder every field-like control and `Button` reads.
5
+ *
6
+ * A row of `<Input/> <Select/> <Button/>` only lines up if each of them gets
7
+ * its height for the same size name from the same place. Two ladders drifting
8
+ * apart is invisible per component and obvious the moment they share a row, so
9
+ * a control never spells `h-9` / `h-10` itself — it reads this map.
10
+ *
11
+ * Tailwind reads complete class tokens only, so every entry is a literal
12
+ * string; never build one by interpolation.
13
+ *
14
+ * - `height` / `minHeight`: the shared token. `minHeight` is for triggers that
15
+ * grow with their content (chips, tags) and must still never be shorter than
16
+ * the fixed control of the same size.
17
+ * - `button`: horizontal padding and type size of a `Button` at this size.
18
+ * - `field`: horizontal and vertical padding and type size of a text field.
19
+ * `default` keeps `text-base md:text-sm` so a phone does not zoom on focus.
20
+ *
21
+ * `huge` exists for `Button` only; no field has one.
22
+ */
23
+ export const CONTROL_SIZE = {
24
+ xs: {
25
+ height: 'h-7',
26
+ minHeight: 'min-h-7',
27
+ button: 'px-2 py-1 text-xs',
28
+ field: 'px-2 py-0.5 text-xs',
29
+ },
30
+ sm: {
31
+ height: 'h-8',
32
+ minHeight: 'min-h-8',
33
+ button: 'px-3 text-sm',
34
+ field: 'px-2.5 py-1 text-sm',
35
+ },
36
+ default: {
37
+ height: 'h-9',
38
+ minHeight: 'min-h-9',
39
+ button: 'px-4 py-2',
40
+ field: 'px-3 py-1 text-base md:text-sm',
41
+ },
42
+ lg: {
43
+ height: 'h-11',
44
+ minHeight: 'min-h-11',
45
+ button: 'px-8',
46
+ field: 'px-4 py-2 text-base',
47
+ },
48
+ huge: {
49
+ height: 'h-14',
50
+ minHeight: 'min-h-14',
51
+ button: 'px-10 text-lg',
52
+ },
53
+ } as const;
54
+
55
+ /** Every size name on the ladder. */
56
+ export type ControlSize = keyof typeof CONTROL_SIZE;
57
+
58
+ /** The sizes a text field offers — the ladder without `huge`. */
59
+ export type FieldControlSize = Exclude<ControlSize, 'huge'>;
60
+
61
+ /** Height, padding and type size of a fixed-height field at `size`. */
62
+ export const fieldSizeClass = (size: FieldControlSize = 'default'): string =>
63
+ cn(CONTROL_SIZE[size].height, CONTROL_SIZE[size].field);
64
+
65
+ /** Height, padding and type size of a `Button` at `size`. */
66
+ export const buttonSizeClass = (size: ControlSize = 'default'): string =>
67
+ cn(CONTROL_SIZE[size].height, CONTROL_SIZE[size].button);
@@ -15,6 +15,7 @@ import { useIsTouch } from '../../../hooks/media';
15
15
  import { Popover, PopoverContent, PopoverTrigger } from '../../overlay/popover';
16
16
  import { Calendar } from '../../data/calendar/calendar';
17
17
  import { formatDateString, parseDateString } from './date-time-utils';
18
+ import type { FieldControlSize } from '../control-size';
18
19
  import { NativeFieldShell, NATIVE_FIELD_INPUT_CLASS } from './native-field-shell';
19
20
  import { IconTrigger, PopoverChevron } from './popover-chevron';
20
21
 
@@ -49,6 +50,8 @@ export interface DateFieldProps {
49
50
  */
50
51
  id?: string;
51
52
 
53
+ /** Height from the shared control ladder — same names as `Input`'s `inputSize`. */
54
+ inputSize?: FieldControlSize;
52
55
  className?: string;
53
56
  align?: 'start' | 'center' | 'end';
54
57
  'aria-label'?: string;
@@ -67,6 +70,7 @@ export const DateField = React.forwardRef<HTMLInputElement, DateFieldProps>(
67
70
  showPopover,
68
71
  name,
69
72
  id,
73
+ inputSize,
70
74
  className,
71
75
  align = 'start',
72
76
  'aria-label': ariaLabel,
@@ -112,6 +116,7 @@ export const DateField = React.forwardRef<HTMLInputElement, DateFieldProps>(
112
116
  iconOpensPopover={withPopover}
113
117
  invalid={invalid}
114
118
  disabled={disabled}
119
+ inputSize={inputSize}
115
120
  className={className}
116
121
  popoverTrigger={
117
122
  withPopover ? (
@@ -14,6 +14,7 @@ import { CalendarClock, Clock } from 'lucide-react';
14
14
  import * as React from 'react';
15
15
 
16
16
  import { cn } from '../../../lib/utils';
17
+ import { CONTROL_SIZE, type FieldControlSize } from '../control-size';
17
18
  import { useIsTouch } from '../../../hooks/media';
18
19
  import { Popover, PopoverContent, PopoverTrigger } from '../../overlay/popover';
19
20
  import { Calendar } from '../../data/calendar/calendar';
@@ -58,6 +59,8 @@ export interface DateTimeFieldProps {
58
59
  */
59
60
  id?: string;
60
61
 
62
+ /** Height from the shared control ladder — same names as `Input`'s `inputSize`. */
63
+ inputSize?: FieldControlSize;
61
64
  className?: string;
62
65
  align?: 'start' | 'center' | 'end';
63
66
  'aria-label'?: string;
@@ -76,6 +79,7 @@ export const DateTimeField = React.forwardRef<HTMLInputElement, DateTimeFieldPro
76
79
  showPopover,
77
80
  name,
78
81
  id,
82
+ inputSize,
79
83
  className,
80
84
  align = 'start',
81
85
  'aria-label': ariaLabel,
@@ -120,6 +124,7 @@ export const DateTimeField = React.forwardRef<HTMLInputElement, DateTimeFieldPro
120
124
  iconOpensPopover={withPopover}
121
125
  invalid={invalid}
122
126
  disabled={disabled}
127
+ inputSize={inputSize}
123
128
  className={className}
124
129
  popoverTrigger={
125
130
  withPopover ? (
@@ -179,7 +184,7 @@ export const DateTimeField = React.forwardRef<HTMLInputElement, DateTimeFieldPro
179
184
  />
180
185
  {/* Time row: a full, comfortable field — not a cramped chip. The
181
186
  * label + clock icon sit left; the native time input fills the
182
- * rest at the same height as the main field (h-10) so it's an easy
187
+ * rest at the default control height so it's an easy
183
188
  * tap/click target and reads as a real input. */}
184
189
  <div className="flex items-center gap-2 border-t p-3">
185
190
  <Clock className="size-4 shrink-0 text-muted-foreground" aria-hidden />
@@ -195,7 +200,8 @@ export const DateTimeField = React.forwardRef<HTMLInputElement, DateTimeFieldPro
195
200
  className={cn(
196
201
  // `--center` variant centers the native segments (see
197
202
  // datetime-field.css); it sets its own display/justify.
198
- 'native-datetime-input native-datetime-input--center ml-auto h-10 rounded-[var(--radius)]',
203
+ 'native-datetime-input native-datetime-input--center ml-auto rounded-[var(--radius)]',
204
+ CONTROL_SIZE.default.height,
199
205
  'border border-border bg-input px-4 text-base tabular-nums text-foreground shadow-sm md:text-sm',
200
206
  'focus-visible:border-ring focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
201
207
  'disabled:cursor-not-allowed disabled:opacity-50',
@@ -22,6 +22,7 @@
22
22
  import * as React from 'react';
23
23
 
24
24
  import { cn } from '../../../lib/utils';
25
+ import { CONTROL_SIZE, type FieldControlSize } from '../control-size';
25
26
 
26
27
  export interface NativeFieldShellProps {
27
28
  /** Leading icon (lucide element). */
@@ -38,6 +39,8 @@ export interface NativeFieldShellProps {
38
39
  * driven by the surrounding `Field` / `Form` layer. */
39
40
  invalid?: boolean;
40
41
  disabled?: boolean;
42
+ /** Height from the shared control ladder — same names as `Input`'s `inputSize`. */
43
+ inputSize?: FieldControlSize;
41
44
  className?: string;
42
45
  /** The native `<input>` element, already typed and wired by the field. */
43
46
  children: React.ReactNode;
@@ -71,7 +74,7 @@ export const NATIVE_FIELD_INPUT_CLASS = cn(
71
74
  * sharp Vercel/Linear look rather than a blurry halo.
72
75
  */
73
76
  export const NativeFieldShell = React.forwardRef<HTMLDivElement, NativeFieldShellProps>(
74
- ({ icon, iconOpensPopover, invalid, disabled, className, children, popoverTrigger }, ref) => {
77
+ ({ icon, iconOpensPopover, invalid, disabled, inputSize = 'default', className, children, popoverTrigger }, ref) => {
75
78
  return (
76
79
  <div
77
80
  ref={ref}
@@ -79,7 +82,8 @@ export const NativeFieldShell = React.forwardRef<HTMLDivElement, NativeFieldShel
79
82
  data-disabled={disabled ? '' : undefined}
80
83
  aria-invalid={invalid || undefined}
81
84
  className={cn(
82
- 'flex h-10 w-full items-center gap-2 rounded-[var(--radius)] border bg-input px-3 shadow-sm',
85
+ CONTROL_SIZE[inputSize].height,
86
+ 'flex w-full items-center gap-2 rounded-[var(--radius)] border bg-input px-3 shadow-sm',
83
87
  'transition-[color,background-color,border-color,box-shadow]',
84
88
  'border-border',
85
89
  // Crisp focus edge on the whole shell (border + 1px ring = ~2px clean
@@ -14,6 +14,7 @@
14
14
  import { Clock } from 'lucide-react';
15
15
  import * as React from 'react';
16
16
 
17
+ import type { FieldControlSize } from '../control-size';
17
18
  import { NativeFieldShell, NATIVE_FIELD_INPUT_CLASS } from './native-field-shell';
18
19
 
19
20
  export interface TimeFieldProps {
@@ -40,6 +41,8 @@ export interface TimeFieldProps {
40
41
  * the field a user actually focuses.
41
42
  */
42
43
  id?: string;
44
+ /** Height from the shared control ladder — same names as `Input`'s `inputSize`. */
45
+ inputSize?: FieldControlSize;
43
46
  className?: string;
44
47
  'aria-label'?: string;
45
48
  }
@@ -57,6 +60,7 @@ export const TimeField = React.forwardRef<HTMLInputElement, TimeFieldProps>(
57
60
  step,
58
61
  name,
59
62
  id,
63
+ inputSize,
60
64
  className,
61
65
  'aria-label': ariaLabel,
62
66
  },
@@ -79,6 +83,7 @@ export const TimeField = React.forwardRef<HTMLInputElement, TimeFieldProps>(
79
83
  icon={<Clock aria-hidden />}
80
84
  invalid={invalid}
81
85
  disabled={disabled}
86
+ inputSize={inputSize}
82
87
  className={className}
83
88
  >
84
89
  <input
@@ -3,6 +3,7 @@
3
3
  import * as React from "react";
4
4
 
5
5
  import { cn } from "../../../lib/utils";
6
+ import type { FieldControlSize } from "../control-size";
6
7
  import { inputClass, TEXTAREA_CLASS } from "../input";
7
8
 
8
9
  // =============================================================================
@@ -45,7 +46,7 @@ export type EditablePreviewProps = React.ComponentPropsWithoutRef<"span">;
45
46
 
46
47
  export interface EditableInputProps extends Omit<React.ComponentPropsWithoutRef<"input">, "value" | "defaultValue"> {
47
48
  /** Density — matches the standalone Input's `inputSize`. Default: 'default'. */
48
- inputSize?: 'default' | 'sm';
49
+ inputSize?: FieldControlSize;
49
50
  }
50
51
 
51
52
  export type EditableTextareaProps = Omit<React.ComponentPropsWithoutRef<"textarea">, "value" | "defaultValue">;
@@ -2,6 +2,7 @@ import * as React from 'react';
2
2
 
3
3
  import { useStoredValue, type StorageType, type UseStoredValueOptions } from '../../../hooks';
4
4
  import { cn } from '../../../lib/utils';
5
+ import { fieldSizeClass, type FieldControlSize } from '../control-size';
5
6
 
6
7
  export interface InputProps extends React.ComponentProps<"input"> {
7
8
  /**
@@ -30,11 +31,14 @@ export interface InputProps extends React.ComponentProps<"input"> {
30
31
  */
31
32
  storageTtl?: number;
32
33
  /**
33
- * Control height/density.
34
- * - `default` — 40px (forms, standalone fields).
35
- * - `sm` — 36px, smaller text (inline edits, dense rows, settings chips).
34
+ * Control height, from the shared ladder in `forms/control-size.ts` — the
35
+ * same name gives a `Button` and a `SelectTrigger` the same height.
36
+ * - `xs` — 28px.
37
+ * - `sm` — 32px.
38
+ * - `default` — 36px.
39
+ * - `lg` — 44px.
36
40
  */
37
- inputSize?: 'default' | 'sm';
41
+ inputSize?: FieldControlSize;
38
42
  /**
39
43
  * Drop the field chrome — border, surface, rounding, focus ring — for an
40
44
  * input that sits inside a control that draws them itself (`InputGroup`).
@@ -76,8 +80,10 @@ const INPUT_CHROME =
76
80
  const INPUT_BASE = cn(INPUT_CORE, INPUT_CHROME);
77
81
 
78
82
  const INPUT_SIZE: Record<NonNullable<InputProps['inputSize']>, string> = {
79
- default: "h-10 px-3 py-2 text-base md:text-sm",
80
- sm: "h-9 px-2.5 py-1.5 text-sm",
83
+ xs: fieldSizeClass('xs'),
84
+ sm: fieldSizeClass('sm'),
85
+ default: fieldSizeClass('default'),
86
+ lg: fieldSizeClass('lg'),
81
87
  };
82
88
 
83
89
  /** Resolve the full input class for a given size. */
@@ -146,7 +152,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
146
152
  return (
147
153
  <input
148
154
  type={type}
149
- className={cn(INPUT_CLASS, className)}
155
+ className={resolvedClass}
150
156
  ref={ref}
151
157
  defaultValue={storageKey && storedValue ? storedValue : defaultValue}
152
158
  onChange={storageKey ? handleChange : onChange}
@@ -3,6 +3,7 @@
3
3
  import * as React from 'react';
4
4
 
5
5
  import { cn } from '../../../lib/utils';
6
+ import type { FieldControlSize } from '../control-size';
6
7
  import {
7
8
  InputGroup,
8
9
  InputGroupAddon,
@@ -41,11 +42,13 @@ export interface InputAffixProps
41
42
  suffix?: React.ReactNode;
42
43
  /** Applied to the group, so callers can size the whole control. */
43
44
  groupClassName?: string;
45
+ /** Height from the shared control ladder — same names as `Input`'s `inputSize`. */
46
+ inputSize?: FieldControlSize;
44
47
  }
45
48
 
46
49
  const InputAffix = React.forwardRef<HTMLInputElement, InputAffixProps>(
47
- ({ prefix, suffix, className, groupClassName, ...props }, ref) => (
48
- <InputGroup className={groupClassName}>
50
+ ({ prefix, suffix, className, groupClassName, inputSize, ...props }, ref) => (
51
+ <InputGroup className={groupClassName} inputSize={inputSize}>
49
52
  {prefix ? (
50
53
  <InputGroupAddon align="inline-start" aria-hidden="true">
51
54
  {prefix}
@@ -5,10 +5,18 @@ import * as React from 'react';
5
5
 
6
6
  import { cn } from '../../../lib/utils';
7
7
  import { Button } from '../../forms/button';
8
+ import { CONTROL_SIZE, type FieldControlSize } from '../control-size';
8
9
  import { Input } from '../../forms/input';
9
10
  import { Textarea } from '../../forms/textarea';
10
11
 
11
- function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
12
+ function InputGroup({
13
+ className,
14
+ inputSize = "default",
15
+ ...props
16
+ }: React.ComponentProps<"div"> & {
17
+ /** Height from the shared control ladder — the same name as `Input`'s `inputSize`. */
18
+ inputSize?: FieldControlSize
19
+ }) {
12
20
  return (
13
21
  <div
14
22
  data-slot="input-group"
@@ -20,18 +28,12 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
20
28
  // nearly invisible next to a plain field's, and a focused money input
21
29
  // showed a crisp inner rectangle inside a ghost of an outer one.
22
30
  "group/input-group border-border bg-input shadow-xs relative flex w-full items-center rounded-[var(--radius)] border outline-none transition-[color,background-color,border-color,box-shadow]",
23
- // Matches `Input`'s default size (h-10), not its `sm` one.
24
- //
25
- // This was `h-9`, which made a grouped input a row shorter than a bare
26
- // `Input` or a `SelectTrigger` beside it — visible the moment a form
27
- // puts a prefixed money field next to a plain select, and not
28
- // attributable to either component from the outside. A wrapper that
29
- // changes a control's height is a wrapper that cannot be dropped into
30
- // an existing row.
31
- //
32
- // Use `className="h-9"` at the call site for the compact size, the
33
- // same way `inputSize="sm"` is opted into rather than inherited.
34
- "h-10 has-[>textarea]:h-auto",
31
+ // Height comes from the shared control ladder, the same map `Input` and
32
+ // `SelectTrigger` read, so a grouped input is never a row shorter or
33
+ // taller than a bare one beside it. A wrapper that changes a control's
34
+ // height cannot be dropped into an existing row.
35
+ CONTROL_SIZE[inputSize].height,
36
+ "has-[>textarea]:h-auto",
35
37
 
36
38
  // Variants based on alignment.
37
39
  //
@@ -3,6 +3,7 @@
3
3
  import * as React from "react";
4
4
 
5
5
  import { cn } from "../../../lib/utils";
6
+ import { CONTROL_SIZE } from "../control-size";
6
7
 
7
8
  // =============================================================================
8
9
  // Types
@@ -458,7 +459,8 @@ const MaskInput = React.forwardRef<HTMLInputElement, MaskInputProps>(
458
459
  ref={composedRef}
459
460
  value={displayValue}
460
461
  className={cn(
461
- "flex h-10 w-full rounded-[var(--radius)] border border-border 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",
462
+ CONTROL_SIZE.default.height,
463
+ "flex w-full rounded-[var(--radius)] border border-border bg-transparent px-3 py-1 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",
462
464
  className
463
465
  )}
464
466
  onChange={handleChange}
@@ -209,8 +209,7 @@ const PhoneInput = React.forwardRef<HTMLInputElement, PhoneInputProps>(
209
209
  <DropdownMenuTrigger asChild>
210
210
  <Button
211
211
  variant="outline"
212
- size="sm"
213
- className="h-10 px-3 rounded-r-none border-r-0 flex items-center gap-2"
212
+ className="px-3 rounded-r-none border-r-0 flex items-center gap-2"
214
213
  disabled={disabled}
215
214
  >
216
215
  <Flag countryCode={currentCountry.code} rounded className="h-3 w-4" />
@@ -227,7 +226,8 @@ const PhoneInput = React.forwardRef<HTMLInputElement, PhoneInputProps>(
227
226
  placeholder={translations.searchCountries}
228
227
  value={searchQuery}
229
228
  onChange={(e) => setSearchQuery(e.target.value)}
230
- className="pl-8 h-8"
229
+ inputSize="sm"
230
+ className="pl-8"
231
231
  autoFocus
232
232
  />
233
233
  </div>
@@ -12,6 +12,8 @@ export type { TouchSafeInputProps, TouchSafeTextareaProps, TouchSafeSelectProps
12
12
  export { Button, ButtonLink, buttonVariants } from './forms/button';
13
13
  export type { ButtonProps, ButtonLinkProps } from './forms/button';
14
14
  export { Input } from './forms/input';
15
+ export { CONTROL_SIZE } from './forms/control-size';
16
+ export type { ControlSize, FieldControlSize } from './forms/control-size';
15
17
  export { Textarea } from './forms/textarea';
16
18
  export { Label } from './forms/label';
17
19
  export { Checkbox } from './forms/checkbox';
@@ -10,6 +10,7 @@ import { LanguageFlag } from '../specialized/flag';
10
10
  import { Badge } from '../data/badge';
11
11
  import { Button } from '../forms/button';
12
12
  import { Checkbox } from '../forms/checkbox';
13
+ import { CONTROL_SIZE } from '../forms/control-size';
13
14
  import {
14
15
  Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList
15
16
  } from '../navigation/command';
@@ -363,6 +364,14 @@ export function LanguageSelect({
363
364
  }
364
365
 
365
366
  // Dropdown variant (displayValue is memoized above, before the inline guard)
367
+ const triggerSize = size === 'sm' ? 'sm' : 'default';
368
+ // Single-select: a fixed-height pill (the Button size sets height).
369
+ // Multiple: allow growth so wrapped badges aren't clipped, never shorter than
370
+ // the fixed control of the same size.
371
+ const triggerSizeClass = multiple
372
+ ? cn('h-auto', CONTROL_SIZE[triggerSize].minHeight, size === 'sm' ? 'py-1' : 'py-2')
373
+ : size === 'sm' && 'text-xs';
374
+
366
375
  return (
367
376
  <Popover
368
377
  open={open}
@@ -376,19 +385,10 @@ export function LanguageSelect({
376
385
  <PopoverTrigger asChild>
377
386
  <Button
378
387
  variant="outline"
379
- size={size === 'sm' ? 'sm' : 'default'}
388
+ size={triggerSize}
380
389
  role="combobox"
381
390
  aria-expanded={open}
382
- className={cn(
383
- SELECT_TRIGGER_CLASS,
384
- // Single-select: a fixed-height pill (the Button size sets height).
385
- // Multiple: allow growth so wrapped badges aren't clipped — keep the
386
- // auto-height min behaviour, scaled to the chosen size.
387
- multiple
388
- ? (size === 'sm' ? "min-h-8 h-auto py-1" : "min-h-10 h-auto py-2")
389
- : (size === 'sm' ? "text-xs" : "h-10"),
390
- className
391
- )}
391
+ className={cn(SELECT_TRIGGER_CLASS, triggerSizeClass, className)}
392
392
  disabled={disabled}
393
393
  >
394
394
  <div className="flex-1 text-left overflow-hidden">
@@ -7,6 +7,7 @@ import * as SelectPrimitive from '@radix-ui/react-select';
7
7
 
8
8
  import { cn } from '../../lib/utils';
9
9
  import { Badge } from '../data/badge';
10
+ import { CONTROL_SIZE, type FieldControlSize } from '../forms/control-size';
10
11
 
11
12
  /**
12
13
  * Radix `<Select.Item value="" />` is reserved (it's how Radix represents
@@ -59,14 +60,23 @@ const SelectTrigger = React.forwardRef<
59
60
  * status bar or toolbar where the trigger should read like a plain button.
60
61
  */
61
62
  variant?: "default" | "ghost";
63
+ /**
64
+ * Height from the shared control ladder (`forms/control-size.ts`): the same
65
+ * name as a `Button` or `Input` gives the same height. Ignored by `ghost`,
66
+ * which has no fixed height.
67
+ */
68
+ size?: FieldControlSize;
62
69
  }
63
- >(({ className, children, icon: Icon, variant = "default", ...props }, ref) => (
70
+ >(({ className, children, icon: Icon, variant = "default", size = "default", ...props }, ref) => (
64
71
  <SelectPrimitive.Trigger
65
72
  ref={ref}
66
73
  className={cn(
67
74
  "flex items-center justify-between whitespace-nowrap text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50",
68
75
  variant === "default" &&
69
- "h-10 w-full rounded-[var(--radius)] border border-border bg-transparent px-3 py-2 shadow-sm focus:ring-1 focus:ring-ring",
76
+ cn(
77
+ CONTROL_SIZE[size].height,
78
+ "w-full rounded-[var(--radius)] border border-border bg-transparent px-3 shadow-sm focus:ring-1 focus:ring-ring",
79
+ ),
70
80
  variant === "ghost" &&
71
81
  "rounded-md px-2 py-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[state=open]:bg-muted data-[state=open]:text-foreground",
72
82
  className
@@ -1,3 +1,6 @@
1
+ import { cn } from '../../lib/utils';
2
+ import { CONTROL_SIZE } from '../forms/control-size';
3
+
1
4
  /**
2
5
  * The shared class list for a select-like trigger.
3
6
  *
@@ -19,5 +22,7 @@ export const SELECT_TRIGGER_CLASS = "w-full justify-between font-normal";
19
22
  * The same, for pickers whose trigger grows with its content (chips, tags,
20
23
  * multiple rows) instead of keeping a fixed control height.
21
24
  */
22
- export const SELECT_TRIGGER_MULTI_CLASS =
23
- "w-full justify-between font-normal min-h-10 h-auto py-2";
25
+ export const SELECT_TRIGGER_MULTI_CLASS = cn(
26
+ "w-full justify-between font-normal h-auto py-2",
27
+ CONTROL_SIZE.default.minHeight,
28
+ );
@@ -19,3 +19,19 @@ export { useStateMachine } from './useStateMachine';
19
19
  export type { StateMachineConfig } from './useStateMachine';
20
20
  export { useCallbackRef } from './useCallbackRef';
21
21
  export { usePrevious } from './usePrevious';
22
+ export {
23
+ DEFAULT_STORAGE_NAMESPACE,
24
+ StorageNamespaceProvider,
25
+ useStorageNamespace,
26
+ resolveStorageKey,
27
+ isSchemeKey,
28
+ listStorageKeys,
29
+ clearStorageNamespace,
30
+ createStorageNamespace,
31
+ } from './storage-namespace';
32
+ export type {
33
+ StorageNamespace,
34
+ StorageNamespaceProviderProps,
35
+ StorageArea,
36
+ ClearStorageOptions,
37
+ } from './storage-namespace';
@@ -0,0 +1,215 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Storage namespaces for the state hooks.
5
+ *
6
+ * Every key we write to the browser follows one scheme (full rules:
7
+ * `@djangocfg/api` → `@docs/STORAGE_KEYS.md`):
8
+ *
9
+ * `<owner>.<area>.<name>[.<id>]` e.g. `cfg.ui.sidebar_width`, `kino.player.volume`
10
+ *
11
+ * The hooks (`useLocalStorage`, `useSessionStorage`, `useStoredValue`) enforce
12
+ * it by resolving the key you pass:
13
+ *
14
+ * 1. A key that already follows the scheme (`kino.player.volume`) is used as is.
15
+ * 2. A bare name (`sidebar-width`, `Last Volume`) is folded to `snake_case` and
16
+ * placed under the active namespace: `<owner>.ui.sidebar_width`.
17
+ *
18
+ * The active owner is, in order: the hook's `namespace` option, the nearest
19
+ * `<StorageNamespaceProvider owner="…">`, then `cfg` (the framework default).
20
+ * An app sets its owner once at the root and forgets about it.
21
+ */
22
+
23
+ import { createContext, createElement, useContext, type ReactNode } from 'react';
24
+ import {
25
+ CFG_FAMILIES,
26
+ CFG_KEYS,
27
+ createStorage,
28
+ normalizeSegment,
29
+ type GuardedStorage,
30
+ } from '@djangocfg/api/storage';
31
+
32
+ /** The framework's owner. Used when nothing else is declared. */
33
+ export const DEFAULT_STORAGE_NAMESPACE = 'cfg';
34
+
35
+ /** Area given to a bare key. Hook-level state is UI state unless the caller names the area. */
36
+ export const DEFAULT_STORAGE_AREA = 'ui';
37
+
38
+ const OWNER_RE = /^[a-z][a-z0-9_]*$/;
39
+ const SCHEME_RE = /^[a-z][a-z0-9_]*(\.[a-z0-9_]+)+$/;
40
+
41
+ const NamespaceContext = createContext<string>(DEFAULT_STORAGE_NAMESPACE);
42
+
43
+ export interface StorageNamespaceProviderProps {
44
+ /** Short lower-case owner for every key written below: `kino`, `mls`, `crm`. */
45
+ owner: string;
46
+ children?: ReactNode;
47
+ }
48
+
49
+ /**
50
+ * Declares the owner of every bare storage key in the subtree.
51
+ * Place it once, near the root of an app.
52
+ */
53
+ export function StorageNamespaceProvider({ owner, children }: StorageNamespaceProviderProps) {
54
+ if (!OWNER_RE.test(owner)) {
55
+ throw new TypeError(`StorageNamespaceProvider: owner "${owner}" must match ${OWNER_RE}`);
56
+ }
57
+ return createElement(NamespaceContext.Provider, { value: owner }, children);
58
+ }
59
+
60
+ /** The owner the hooks use here (the provider's, or `cfg`). */
61
+ export function useStorageNamespace(): string {
62
+ return useContext(NamespaceContext);
63
+ }
64
+
65
+ /** True when `key` already follows `<owner>.<area>.<name>`. */
66
+ export function isSchemeKey(key: string): boolean {
67
+ return SCHEME_RE.test(key);
68
+ }
69
+
70
+ /**
71
+ * Pure resolver behind the hooks: scheme keys pass through, bare names are
72
+ * folded and prefixed with `<owner>.ui.`. Throws when nothing usable is left.
73
+ */
74
+ export function resolveStorageKey(key: string, owner: string = DEFAULT_STORAGE_NAMESPACE): string {
75
+ if (isSchemeKey(key)) return key;
76
+ const name = normalizeSegment(key);
77
+ if (!name) throw new TypeError(`storage key "${key}" is empty after normalisation`);
78
+ return `${owner}.${DEFAULT_STORAGE_AREA}.${name}`;
79
+ }
80
+
81
+ /** Hook form: owner = option → provider → `cfg`. */
82
+ export function useResolvedStorageKey(key: string, namespace?: string): string {
83
+ const fromContext = useStorageNamespace();
84
+ return resolveStorageKey(key, namespace ?? fromContext);
85
+ }
86
+
87
+ // ---- eviction (shared by the quota-recovery paths of the hooks) -------------
88
+
89
+ const PERSONAL_KEYS = new Set(
90
+ Object.values(CFG_KEYS)
91
+ .filter((spec) => spec.personal)
92
+ .map((spec) => spec.key),
93
+ );
94
+ const PERSONAL_PREFIXES = Object.values(CFG_FAMILIES)
95
+ .filter((spec) => spec.personal)
96
+ .map((spec) => spec.prefix);
97
+
98
+ /**
99
+ * May the quota-recovery code drop this key? Only keys that follow our scheme
100
+ * and are not personal (tokens, profile, drafts). Third-party keys such as
101
+ * `NEXT_LOCALE` and everything signed-in-user related are never evicted.
102
+ */
103
+ export function isEvictableKey(key: string): boolean {
104
+ if (!isSchemeKey(key)) return false;
105
+ if (key.startsWith('cfg.auth.') || PERSONAL_KEYS.has(key)) return false;
106
+ return !PERSONAL_PREFIXES.some((prefix) => key.startsWith(prefix));
107
+ }
108
+
109
+ /** Names in `storage` that the quota recovery is allowed to remove. */
110
+ export function evictableKeys(storage: Storage): string[] {
111
+ const out: string[] = [];
112
+ for (let i = 0; i < storage.length; i += 1) {
113
+ const name = storage.key(i);
114
+ if (name && isEvictableKey(name)) out.push(name);
115
+ }
116
+ return out;
117
+ }
118
+
119
+ // ---- inspection / reset ------------------------------------------------------
120
+
121
+ export type StorageArea = 'local' | 'session';
122
+
123
+ function nativeArea(area: StorageArea): Storage | null {
124
+ if (typeof window === 'undefined') return null;
125
+ try {
126
+ return area === 'session' ? window.sessionStorage : window.localStorage;
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ /** Every key an owner holds in one store (`cfg` by default). */
133
+ export function listStorageKeys(owner: string = DEFAULT_STORAGE_NAMESPACE, area: StorageArea = 'local'): string[] {
134
+ const native = nativeArea(area);
135
+ if (!native) return [];
136
+ const prefix = `${owner}.`;
137
+ const out: string[] = [];
138
+ for (let i = 0; i < native.length; i += 1) {
139
+ const name = native.key(i);
140
+ if (name?.startsWith(prefix)) out.push(name);
141
+ }
142
+ return out.sort();
143
+ }
144
+
145
+ export interface ClearStorageOptions {
146
+ /** Store to clear. Default: both. */
147
+ area?: StorageArea;
148
+ /** Also remove personal keys (tokens, profile, drafts). Default false: preferences only. */
149
+ includePersonal?: boolean;
150
+ }
151
+
152
+ /**
153
+ * Removes the keys of one owner. By default personal keys survive, so a
154
+ * "reset preferences" button never signs the user out.
155
+ * Hooks mounted on a removed key keep their in-memory value until they write.
156
+ */
157
+ export function clearStorageNamespace(
158
+ owner: string = DEFAULT_STORAGE_NAMESPACE,
159
+ { area, includePersonal = false }: ClearStorageOptions = {},
160
+ ): string[] {
161
+ const areas: StorageArea[] = area ? [area] : ['local', 'session'];
162
+ const removed: string[] = [];
163
+ for (const a of areas) {
164
+ const native = nativeArea(a);
165
+ if (!native) continue;
166
+ for (const name of listStorageKeys(owner, a)) {
167
+ if (!includePersonal && owner === DEFAULT_STORAGE_NAMESPACE && !isEvictableKey(name)) continue;
168
+ try {
169
+ native.removeItem(name);
170
+ removed.push(name);
171
+ } catch {
172
+ /* ignore */
173
+ }
174
+ }
175
+ }
176
+ return removed;
177
+ }
178
+
179
+ // ---- an app's namespace -------------------------------------------------------
180
+
181
+ export interface StorageNamespace {
182
+ readonly owner: string;
183
+ /** `kino.player.volume` from `('player', 'volume')`; parts are folded to snake_case. */
184
+ key(...parts: string[]): string;
185
+ /** Guarded non-React access (`read`, `write`, `readJSON`, `onChange`, `clearAll`…). */
186
+ readonly storage: GuardedStorage;
187
+ keys(area?: StorageArea): string[];
188
+ clear(options?: ClearStorageOptions): string[];
189
+ }
190
+
191
+ /**
192
+ * Declares an app's namespace in one place:
193
+ *
194
+ * ```ts
195
+ * // lib/storage.ts
196
+ * export const kinoStorage = createStorageNamespace('kino');
197
+ * export const PLAYER_VOLUME = kinoStorage.key('player', 'volume'); // kino.player.volume
198
+ *
199
+ * // component
200
+ * const [volume, setVolume] = useLocalStorage(PLAYER_VOLUME, 1);
201
+ * ```
202
+ *
203
+ * and wraps the app root in `<StorageNamespaceProvider owner="kino">` so bare
204
+ * `storageKey` props on ui-core components land under `kino.ui.*`.
205
+ */
206
+ export function createStorageNamespace(owner: string): StorageNamespace {
207
+ const storage = createStorage(owner);
208
+ return {
209
+ owner,
210
+ key: (...parts) => storage.key(...parts),
211
+ storage,
212
+ keys: (area = 'local') => listStorageKeys(owner, area),
213
+ clear: (options) => clearStorageNamespace(owner, options),
214
+ };
215
+ }
@@ -3,6 +3,7 @@
3
3
  import { useCallback, useEffect, useRef, useState } from 'react';
4
4
 
5
5
  import { isQuotaExceededError } from './storage-quota';
6
+ import { evictableKeys, useResolvedStorageKey } from './storage-namespace';
6
7
 
7
8
  /**
8
9
  * Storage wrapper format with metadata
@@ -27,6 +28,12 @@ export interface UseLocalStorageOptions {
27
28
  * @example 24 * 60 * 60 * 1000 // 24 hours
28
29
  */
29
30
  ttl?: number;
31
+ /**
32
+ * Owner for a bare key (`'sidebar-width'` → `<owner>.ui.sidebar_width`).
33
+ * Default: the nearest `StorageNamespaceProvider`, else `cfg`.
34
+ * A key that already follows `<owner>.<area>.<name>` is used as is.
35
+ */
36
+ namespace?: string;
30
37
  }
31
38
 
32
39
  /**
@@ -152,10 +159,11 @@ function broadcastKey(
152
159
  * patch({ b: 3 }); // merges → { a: 1, b: 3 }, only writes if it changed
153
160
  */
154
161
  export function useLocalStorage<T>(
155
- key: string,
162
+ keyInput: string,
156
163
  initialValue: T,
157
164
  options?: UseLocalStorageOptions
158
165
  ): UseLocalStorageReturn<T> {
166
+ const key = useResolvedStorageKey(keyInput, options?.namespace);
159
167
  const ttl = options?.ttl;
160
168
  // Always start with initialValue to match SSR
161
169
  const [storedValue, setStoredValue] = useState<T>(initialValue);
@@ -276,43 +284,25 @@ export function useLocalStorage<T>(
276
284
  };
277
285
 
278
286
  // Clear old data when localStorage is full
279
- const clearOldData = () => {
287
+ // Drop evictable keys (our scheme, non-personal) when the quota is full.
288
+ // Tokens, drafts and third-party keys are never touched.
289
+ const evict = (fraction: number) => {
280
290
  try {
281
- const keys = Object.keys(localStorage).filter(key => key && typeof key === 'string');
282
- // Remove oldest items if we have more than 50 items
283
- if (keys.length > 50) {
284
- const itemsToRemove = Math.ceil(keys.length * 0.2);
285
- for (let i = 0; i < itemsToRemove; i++) {
286
- try {
287
- const key = keys[i];
288
- if (key) {
289
- localStorage.removeItem(key);
290
- }
291
- } catch {
292
- // Ignore errors when removing items
293
- }
294
- }
295
- }
296
- } catch (error) {
297
- console.error('Error clearing old localStorage data:', error);
298
- }
299
- };
300
-
301
- // Force clear all data if quota is exceeded
302
- const forceClearAll = () => {
303
- try {
304
- const keys = Object.keys(localStorage);
305
- for (const key of keys) {
291
+ const names = evictableKeys(window.localStorage).filter((name) => name !== key);
292
+ const count = fraction >= 1 ? names.length : Math.ceil(names.length * fraction);
293
+ for (const name of names.slice(0, count)) {
306
294
  try {
307
- localStorage.removeItem(key);
295
+ window.localStorage.removeItem(name);
308
296
  } catch {
309
297
  // Ignore errors when removing items
310
298
  }
311
299
  }
312
300
  } catch (error) {
313
- console.error('Error force clearing localStorage:', error);
301
+ console.error('Error clearing old localStorage data:', error);
314
302
  }
315
303
  };
304
+ const clearOldData = () => evict(0.2);
305
+ const forceClearAll = () => evict(1);
316
306
 
317
307
  // Prepare data for storage (with or without TTL wrapper)
318
308
  const prepareForStorage = (value: T): string => {
@@ -3,6 +3,7 @@
3
3
  import { useState } from 'react';
4
4
 
5
5
  import { isQuotaExceededError } from './storage-quota';
6
+ import { evictableKeys, useResolvedStorageKey } from './storage-namespace';
6
7
 
7
8
  /**
8
9
  * Storage wrapper format with metadata
@@ -27,6 +28,12 @@ export interface UseSessionStorageOptions {
27
28
  * @example 24 * 60 * 60 * 1000 // 24 hours
28
29
  */
29
30
  ttl?: number;
31
+ /**
32
+ * Owner for a bare key (`'wizard-step'` → `<owner>.ui.wizard_step`).
33
+ * Default: the nearest `StorageNamespaceProvider`, else `cfg`.
34
+ * A key that already follows `<owner>.<area>.<name>` is used as is.
35
+ */
36
+ namespace?: string;
30
37
  }
31
38
 
32
39
  /**
@@ -89,10 +96,11 @@ function isExpired<T>(wrapped: StorageWrapper<T>): boolean {
89
96
  * });
90
97
  */
91
98
  export function useSessionStorage<T>(
92
- key: string,
99
+ keyInput: string,
93
100
  initialValue: T,
94
101
  options?: UseSessionStorageOptions
95
102
  ): UseSessionStorageReturn<T> {
103
+ const key = useResolvedStorageKey(keyInput, options?.namespace);
96
104
  const ttl = options?.ttl;
97
105
 
98
106
  // Get initial value from sessionStorage or use provided initialValue
@@ -152,44 +160,25 @@ export function useSessionStorage<T>(
152
160
  }
153
161
  };
154
162
 
155
- // Clear old data when sessionStorage is full
156
- const clearOldData = () => {
163
+ // Drop evictable keys (our scheme, non-personal) when the quota is full.
164
+ // Tokens, drafts and third-party keys are never touched.
165
+ const evict = (fraction: number) => {
157
166
  try {
158
- const keys = Object.keys(sessionStorage).filter(k => k && typeof k === 'string');
159
- // Remove oldest items if we have more than 50 items
160
- if (keys.length > 50) {
161
- const itemsToRemove = Math.ceil(keys.length * 0.2);
162
- for (let i = 0; i < itemsToRemove; i++) {
163
- try {
164
- const k = keys[i];
165
- if (k) {
166
- sessionStorage.removeItem(k);
167
- }
168
- } catch {
169
- // Ignore errors when removing items
170
- }
171
- }
172
- }
173
- } catch (error) {
174
- console.error('Error clearing old sessionStorage data:', error);
175
- }
176
- };
177
-
178
- // Force clear all data if quota is exceeded
179
- const forceClearAll = () => {
180
- try {
181
- const keys = Object.keys(sessionStorage);
182
- for (const k of keys) {
167
+ const names = evictableKeys(window.sessionStorage).filter((name) => name !== key);
168
+ const count = fraction >= 1 ? names.length : Math.ceil(names.length * fraction);
169
+ for (const name of names.slice(0, count)) {
183
170
  try {
184
- sessionStorage.removeItem(k);
171
+ window.sessionStorage.removeItem(name);
185
172
  } catch {
186
173
  // Ignore errors when removing items
187
174
  }
188
175
  }
189
176
  } catch (error) {
190
- console.error('Error force clearing sessionStorage:', error);
177
+ console.error('Error clearing old sessionStorage data:', error);
191
178
  }
192
179
  };
180
+ const clearOldData = () => evict(0.2);
181
+ const forceClearAll = () => evict(1);
193
182
 
194
183
  // Prepare data for storage (with or without TTL wrapper)
195
184
  const prepareForStorage = (value: T): string => {
@@ -19,6 +19,11 @@ export interface UseStoredValueOptions {
19
19
  * @example ttl: 24 * 60 * 60 * 1000 // 24 hours
20
20
  */
21
21
  ttl?: number;
22
+ /**
23
+ * Owner for a bare `storageKey`. Default: nearest `StorageNamespaceProvider`, else `cfg`.
24
+ * @see resolveStorageKey
25
+ */
26
+ namespace?: string;
22
27
  }
23
28
 
24
29
  /**
@@ -41,6 +46,9 @@ export type UseStoredValueReturn<T> = readonly [
41
46
  * Used by components (Input, Combobox, MultiSelect, Tabs, etc.) to add
42
47
  * optional persistence via a single `storageKey` prop.
43
48
  *
49
+ * `storageKey` follows the storage-key scheme (see `./storage-namespace`): a full
50
+ * `owner.area.name` key is used as is, a bare name lands under `<owner>.ui.<name>`.
51
+ *
44
52
  * When storageKey is undefined the hook is a no-op:
45
53
  * - returns initialValue as current value
46
54
  * - setValue / removeValue / patch are no-ops
@@ -55,12 +63,12 @@ export function useStoredValue<T>(
55
63
  options?: UseStoredValueOptions,
56
64
  ): UseStoredValueReturn<T> {
57
65
  const storageType = options?.storage ?? 'local';
58
- const ttlOption = options?.ttl ? { ttl: options.ttl } : undefined;
66
+ const ttlOption = { ttl: options?.ttl, namespace: options?.namespace };
59
67
 
60
68
  // Both hooks are always called (rules of hooks — no conditional calls).
61
69
  // Only one of them is actually used depending on storageType.
62
70
  // When storageKey is undefined we pass a dummy key; the result is discarded.
63
- const dummyKey = '__useStoredValue_noop__';
71
+ const dummyKey = 'cfg.ui.noop';
64
72
 
65
73
  const localResult = useLocalStorage<T>(
66
74
  storageType === 'local' && storageKey ? storageKey : dummyKey,