@djangocfg/ui-core 2.1.602 → 2.1.603

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
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.603",
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.603",
137
+ "@djangocfg/i18n": "^2.1.603",
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.603",
214
+ "@djangocfg/eslint-config": "^2.1.603",
215
+ "@djangocfg/i18n": "^2.1.603",
216
+ "@djangocfg/typescript-config": "^2.1.603",
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
+ );