@ceed/cds 1.39.1 → 1.40.1

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.
@@ -9,23 +9,31 @@ export interface AutocompleteOption {
9
9
  }
10
10
  export type AutocompleteValue<Multiple extends boolean | undefined = false> = Multiple extends true ? string[] : string;
11
11
  export type AutocompleteProps<T extends AutocompleteOption | string = string, Multiple extends boolean | undefined = false> = {
12
- value?: AutocompleteValue<Multiple>;
12
+ /**
13
+ * NOTE: 빈 값으로 시작하는 controlled 필드는 `undefined`가 아니라 `null`을 넘겨야 한다.
14
+ * `undefined`를 넘기면 마운트 시점에 uncontrolled로 판정되어 이후 외부에서의 값 변경이 반영되지 않는다.
15
+ */
16
+ value?: AutocompleteValue<Multiple> | null;
13
17
  defaultValue?: AutocompleteValue<Multiple>;
14
18
  multiple?: Multiple;
15
19
  options: T[];
16
20
  label?: React.ReactNode;
17
21
  error?: boolean;
18
22
  helperText?: React.ReactNode;
23
+ /**
24
+ * NOTE: 빈 값은 `undefined`가 아니라 `null`로 전달된다. 그래야 `value={selected}`로 그대로
25
+ * 되먹여도 controlled가 유지된다.
26
+ */
19
27
  onChange?: (event: {
20
28
  target: {
21
29
  name?: string;
22
- value?: AutocompleteValue<Multiple>;
30
+ value?: AutocompleteValue<Multiple> | null;
23
31
  };
24
32
  }) => void;
25
33
  onChangeComplete?: (event: {
26
34
  target: {
27
35
  name?: string;
28
- value?: AutocompleteValue<Multiple>;
36
+ value?: AutocompleteValue<Multiple> | null;
29
37
  };
30
38
  }) => void;
31
39
  } & Omit<JoyAutocompleteProps<AutocompleteOption, Multiple, boolean, boolean>, 'onChange' | 'value' | 'options' | 'defaultValue'>;
@@ -5,12 +5,20 @@ import type { Currency } from './types';
5
5
  export interface CurrencyInputProps {
6
6
  currency?: Currency;
7
7
  max?: number;
8
- value?: number;
8
+ /**
9
+ * NOTE: 빈 값으로 시작하는 controlled 필드는 `undefined`가 아니라 `null`을 넘겨야 한다.
10
+ * `undefined`를 넘기면 마운트 시점에 uncontrolled로 판정되어 이후 외부에서의 값 변경이 반영되지 않는다.
11
+ */
12
+ value?: number | null;
9
13
  defaultValue?: number;
14
+ /**
15
+ * NOTE: 빈 값은 `undefined`가 아니라 `null`로 전달된다. 그래야 `value={amount}`로 그대로
16
+ * 되먹여도 controlled가 유지된다. 상태는 `useState<number | null>(null)`로 잡으면 된다.
17
+ */
10
18
  onChange?: (event: {
11
19
  target: {
12
20
  name?: string;
13
- value?: number;
21
+ value?: number | null;
14
22
  };
15
23
  }) => void;
16
24
  name?: string;
@@ -9,8 +9,11 @@ export interface DatePickerPreset {
9
9
  interface BaseDatePickerProps {
10
10
  /**
11
11
  * props.format 의 형식을 따라야 한다.
12
+ *
13
+ * NOTE: 빈 값으로 시작하는 controlled 필드는 `undefined`가 아니라 `null`을 넘겨야 한다.
14
+ * `undefined`를 넘기면 마운트 시점에 uncontrolled로 판정되어 이후 외부에서의 값 변경이 반영되지 않는다.
12
15
  */
13
- value?: string;
16
+ value?: string | null;
14
17
  defaultValue?: string;
15
18
  onChange?: (event: {
16
19
  target: {
@@ -9,8 +9,11 @@ export interface DateRangePickerPreset {
9
9
  interface BaseDateRangePickerProps {
10
10
  /**
11
11
  * props.format 의 형식을 따라야 한다.
12
+ *
13
+ * NOTE: 빈 값으로 시작하는 controlled 필드는 `undefined`가 아니라 `null`을 넘겨야 한다.
14
+ * `undefined`를 넘기면 마운트 시점에 uncontrolled로 판정되어 이후 외부에서의 값 변경이 반영되지 않는다.
12
15
  */
13
- value?: string;
16
+ value?: string | null;
14
17
  defaultValue?: string;
15
18
  onChange?: (event: {
16
19
  target: {
@@ -5,7 +5,11 @@ interface FilterableCheckboxGroupOption {
5
5
  disabled?: boolean;
6
6
  }
7
7
  export type FilterableCheckboxGroupProps = {
8
- value?: string[];
8
+ /**
9
+ * NOTE: 빈 값으로 시작하는 controlled 필드는 `undefined`가 아니라 `null`을 넘겨야 한다.
10
+ * `undefined`를 넘기면 마운트 시점에 uncontrolled로 판정되어 이후 외부에서의 값 변경이 반영되지 않는다.
11
+ */
12
+ value?: string[] | null;
9
13
  options: FilterableCheckboxGroupOption[];
10
14
  label?: React.ReactNode;
11
15
  placeholder?: string;
@@ -12,6 +12,11 @@ export type InputProps = {
12
12
  * This has no effect when type is not "password".
13
13
  */
14
14
  disableTogglePasswordButton?: boolean;
15
- } & JoyInputProps & MotionProps;
15
+ /**
16
+ * NOTE: 빈 값으로 시작하는 controlled 필드는 `undefined`가 아니라 `null`을 넘겨야 한다.
17
+ * `undefined`를 넘기면 마운트 시점에 uncontrolled로 판정되어 이후 외부에서의 값 변경이 반영되지 않는다.
18
+ */
19
+ value?: JoyInputProps['value'] | null;
20
+ } & Omit<JoyInputProps, 'value'> & MotionProps;
16
21
  declare const Input: React.ForwardRefExoticComponent<Omit<InputProps, "ref"> & React.RefAttributes<HTMLInputElement>>;
17
22
  export { Input };
@@ -1,7 +1,11 @@
1
1
  import React from 'react';
2
2
  import Input from '../Input';
3
3
  interface BaseMonthPickerProps {
4
- value?: string;
4
+ /**
5
+ * NOTE: 빈 값으로 시작하는 controlled 필드는 `undefined`가 아니라 `null`을 넘겨야 한다.
6
+ * `undefined`를 넘기면 마운트 시점에 uncontrolled로 판정되어 이후 외부에서의 값 변경이 반영되지 않는다.
7
+ */
8
+ value?: string | null;
5
9
  defaultValue?: string;
6
10
  onChange?: (event: {
7
11
  target: {
@@ -1,7 +1,11 @@
1
1
  import React from 'react';
2
2
  import Input from '../Input';
3
3
  interface BaseMonthRangePickerProps {
4
- value?: string;
4
+ /**
5
+ * NOTE: 빈 값으로 시작하는 controlled 필드는 `undefined`가 아니라 `null`을 넘겨야 한다.
6
+ * `undefined`를 넘기면 마운트 시점에 uncontrolled로 판정되어 이후 외부에서의 값 변경이 반영되지 않는다.
7
+ */
8
+ value?: string | null;
5
9
  defaultValue?: string;
6
10
  onChange?: (event: {
7
11
  target: {
@@ -2,12 +2,20 @@ import React from 'react';
2
2
  import { InputProps } from '@mui/joy';
3
3
  import { MotionProps } from 'framer-motion';
4
4
  interface BasePercentageInputProps {
5
- value?: number;
5
+ /**
6
+ * NOTE: 빈 값으로 시작하는 controlled 필드는 `undefined`가 아니라 `null`을 넘겨야 한다.
7
+ * `undefined`를 넘기면 마운트 시점에 uncontrolled로 판정되어 이후 외부에서의 값 변경이 반영되지 않는다.
8
+ */
9
+ value?: number | null;
6
10
  defaultValue?: number;
11
+ /**
12
+ * NOTE: 빈 값은 `undefined`가 아니라 `null`로 전달된다. 그래야 `value={amount}`로 그대로
13
+ * 되먹여도 controlled가 유지된다. 상태는 `useState<number | null>(null)`로 잡으면 된다.
14
+ */
7
15
  onChange?: (event: {
8
16
  target: {
9
17
  name?: string;
10
- value?: number;
18
+ value?: number | null;
11
19
  };
12
20
  }) => void;
13
21
  name?: string;
@@ -1640,6 +1640,95 @@ const columns = [
1640
1640
  - Supported values: `1 | 2`.
1641
1641
  - Works with all existing features: sorting, pinning, resizing, and column grouping.
1642
1642
 
1643
+ ## Column Width
1644
+
1645
+ Columns keep the width you specify. When every column has an explicit `width`, the table appends an
1646
+ invisible filler column that absorbs the space left over, so a `200px` column renders at exactly 200px
1647
+ instead of stretching to fill the container.
1648
+
1649
+ ```tsx
1650
+ <Stack gap={1}>
1651
+ <Typography level="body-sm" textColor="text.secondary">
1652
+ Three 200px columns in a 900px container. Each column keeps exactly 200px instead of
1653
+ stretching to fill the table, and dragging a resize handle moves the edge 1:1.
1654
+ </Typography>
1655
+ <Box
1656
+ sx={{
1657
+ width: "900px"
1658
+ }}
1659
+ >
1660
+ <DataTable rows={widthRows} columns={columns} noWrap />
1661
+ </Box>
1662
+ </Stack>
1663
+ ```
1664
+
1665
+ ```tsx
1666
+ const columns = [
1667
+ { field: 'name', headerName: 'Name', width: '200px' },
1668
+ { field: 'category', headerName: 'Category', width: '200px' },
1669
+ { field: 'calories', headerName: 'Calories', type: 'number', width: '200px' },
1670
+ ];
1671
+ ```
1672
+
1673
+ Dragging a resize handle moves the column edge 1:1 with the pointer — the other columns keep their
1674
+ widths and the filler absorbs the difference.
1675
+
1676
+ ```tsx
1677
+ <Stack gap={1}>
1678
+ <Typography level="body-sm" textColor="text.secondary">
1679
+ Drag the handle on the Name column — the edge follows the pointer 1:1 and the other columns
1680
+ stay put.
1681
+ </Typography>
1682
+ <Box
1683
+ sx={{
1684
+ width: "900px"
1685
+ }}
1686
+ >
1687
+ <DataTable rows={widthRows} columns={columns} noWrap />
1688
+ </Box>
1689
+ </Stack>
1690
+ ```
1691
+
1692
+ ### Letting a Column Fill the Remaining Space
1693
+
1694
+ Omit `width` on a column and it absorbs all the leftover space — no filler column is added.
1695
+
1696
+ ```tsx
1697
+ <Stack gap={1}>
1698
+ <Typography level="body-sm" textColor="text.secondary">
1699
+ Leave a column without a width and it absorbs all the leftover space, exactly as before.
1700
+ </Typography>
1701
+ <Box
1702
+ sx={{
1703
+ width: "900px"
1704
+ }}
1705
+ >
1706
+ <DataTable rows={widthRows} columns={columns} noWrap />
1707
+ </Box>
1708
+ </Stack>
1709
+ ```
1710
+
1711
+ ```tsx
1712
+ const columns = [
1713
+ { field: 'name', headerName: 'Name' }, // fills the remaining space
1714
+ { field: 'category', headerName: 'Category', width: '200px' },
1715
+ { field: 'calories', headerName: 'Calories', type: 'number', width: '200px' },
1716
+ ];
1717
+ ```
1718
+
1719
+ #### Notes
1720
+
1721
+ - Percentage widths (`'40%'`) resolve against the table width, so `40%` really is 40% of the table.
1722
+ - When the columns are wider than the container the filler collapses to 0 and horizontal scrolling
1723
+ works as usual — pinned columns stay pinned.
1724
+ - `minWidth` is enforced by the layout; `maxWidth` is only applied while dragging the resize handle.
1725
+ - Dragging a resize handle now moves the column edge 1:1 with the pointer.
1726
+ - Column groups and the no-rows overlay account for the filler automatically — group `colspan`
1727
+ values and the overlay's `colSpan` are unaffected.
1728
+ - A custom `slots.footer` writes its own `colSpan` values. Those keep aligning with the data
1729
+ columns, but the footer will not extend under the filler column. Add one trailing empty `<td />`
1730
+ if you want the footer to span the full table width.
1731
+
1643
1732
  ## Editing Features
1644
1733
 
1645
1734
  ### Inline Editing
@@ -66,7 +66,8 @@ function CountrySelector() {
66
66
  label="Country"
67
67
  placeholder="Search countries..."
68
68
  options={['United States', 'United Kingdom', 'Canada', 'Australia']}
69
- value={country}
69
+ // NOTE: pass `null`, not `undefined`, while the field is empty - see Props and Customization.
70
+ value={country ?? null}
70
71
  onChange={(e) => setCountry(e.target.value)}
71
72
  />
72
73
  );
@@ -782,36 +783,38 @@ function LazyAutocomplete({ fetchOptions }) {
782
783
 
783
784
  ### Key Props
784
785
 
785
- | Prop | Type | Default | Description |
786
- | ----------------------- | -------------------------------------------------------------- | -------------- | ----------------------------------------------------- |
787
- | `options` | `string[] \| OptionObject[]` | `[]` | Array of options (strings or objects) |
788
- | `value` | `string \| string[]` | - | Selected value(s) for controlled mode |
789
- | `defaultValue` | `string \| string[]` | - | Initial value for uncontrolled mode |
790
- | `onChange` | `(event: { target: { value } }) => void` | - | Callback when selection changes |
791
- | `onInputChange` | `(event: { target: { value } }) => void` | - | Callback when input text changes |
792
- | `label` | `ReactNode` | - | Label text above the input |
793
- | `placeholder` | `string` | - | Placeholder text when empty |
794
- | `loading` | `boolean` | `false` | Show loading indicator |
795
- | `multiple` | `boolean` | `false` | Allow multiple selections |
796
- | `groupBy` | `(option) => string` | - | Function to group options |
797
- | `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Input size |
798
- | `variant` | `'outlined' \| 'soft' \| 'solid' \| 'plain'` | `'outlined'` | Visual style variant |
799
- | `color` | `'primary' \| 'neutral' \| 'danger' \| 'success' \| 'warning'` | `'neutral'` | Color scheme |
800
- | `disabled` | `boolean` | `false` | Disable the input |
801
- | `readOnly` | `boolean` | `false` | Make the input read-only (focusable but not editable) |
802
- | `required` | `boolean` | `false` | Mark the field as required |
803
- | `error` | `boolean` | `false` | Indicate an error state |
804
- | `helperText` | `ReactNode` | - | Helper text below the input |
805
- | `freeSolo` | `boolean` | `false` | Allow arbitrary values not in the options list |
806
- | `disableClearable` | `boolean` | `false` | Hide the clear (X) button |
807
- | `noOptionsText` | `ReactNode` | `'No options'` | Text when no options match |
808
- | `loadingText` | `ReactNode` | `'Loading…'` | Text while loading |
809
- | `limitTags` | `number` | `-1` | Max visible tags in multiple mode (-1 for unlimited) |
810
- | `autoHighlight` | `boolean` | `false` | Auto-highlight first option |
811
- | `clearOnEscape` | `boolean` | `false` | Clear value on Escape key |
812
- | `disableCloseOnSelect` | `boolean` | `false` | Keep popup open after selection |
813
- | `openOnFocus` | `boolean` | `false` | Open popup on focus |
814
- | `filterSelectedOptions` | `boolean` | `false` | Hide selected options from dropdown (multiple mode) |
786
+ | Prop | Type | Default | Description |
787
+ | ----------------------- | ---------------------------------------------------------------------------- | -------------- | ----------------------------------------------------- |
788
+ | `options` | `string[] \| OptionObject[]` | - | Array of options (strings or objects) |
789
+ | `value` | `string \| string[] \| null` | - | Selected value(s) for controlled mode |
790
+ | `defaultValue` | `string \| string[]` | - | Initial value for uncontrolled mode |
791
+ | `onChange` | `(event: { target: { name?, value?: string \| string[] \| null } }) => void` | - | Callback when selection changes |
792
+ | `onInputChange` | `(event: { target: { value } }) => void` | - | Callback when input text changes |
793
+ | `label` | `ReactNode` | - | Label text above the input |
794
+ | `placeholder` | `string` | - | Placeholder text when empty |
795
+ | `loading` | `boolean` | `false` | Show loading indicator |
796
+ | `multiple` | `boolean` | `false` | Allow multiple selections |
797
+ | `groupBy` | `(option) => string` | - | Function to group options |
798
+ | `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Input size |
799
+ | `variant` | `'outlined' \| 'soft' \| 'solid' \| 'plain'` | `'outlined'` | Visual style variant |
800
+ | `color` | `'primary' \| 'neutral' \| 'danger' \| 'success' \| 'warning'` | `'neutral'` | Color scheme |
801
+ | `disabled` | `boolean` | `false` | Disable the input |
802
+ | `readOnly` | `boolean` | `false` | Make the input read-only (focusable but not editable) |
803
+ | `required` | `boolean` | `false` | Mark the field as required |
804
+ | `error` | `boolean` | `false` | Indicate an error state |
805
+ | `helperText` | `ReactNode` | - | Helper text below the input |
806
+ | `freeSolo` | `boolean` | `false` | Allow arbitrary values not in the options list |
807
+ | `disableClearable` | `boolean` | `false` | Hide the clear (X) button |
808
+ | `noOptionsText` | `ReactNode` | `'No options'` | Text when no options match |
809
+ | `loadingText` | `ReactNode` | `'Loading…'` | Text while loading |
810
+ | `limitTags` | `number` | `-1` | Max visible tags in multiple mode (-1 for unlimited) |
811
+ | `autoHighlight` | `boolean` | `false` | Auto-highlight first option |
812
+ | `clearOnEscape` | `boolean` | `false` | Clear value on Escape key |
813
+ | `disableCloseOnSelect` | `boolean` | `false` | Keep popup open after selection |
814
+ | `openOnFocus` | `boolean` | `false` | Open popup on focus |
815
+ | `filterSelectedOptions` | `boolean` | `false` | Hide selected options from dropdown (multiple mode) |
816
+
817
+ > **Note**: For a **controlled** field that starts empty, pass `null` as `value`. Passing `undefined` makes the field uncontrolled from the first render, so later external updates (including resetting it to an empty value) are not reflected on screen.
815
818
 
816
819
  ### Option Object Structure
817
820
 
@@ -852,11 +855,12 @@ const options = [
852
855
  ### Controlled vs Uncontrolled
853
856
 
854
857
  ```tsx
855
- // Controlled - manage value externally
858
+ // Controlled - manage value externally.
859
+ // NOTE: pass `null`, not `undefined`, while the field is empty - see the note under Props.
856
860
  function ControlledExample() {
857
861
  const [value, setValue] = useState<string | undefined>();
858
862
 
859
- return <Autocomplete value={value} onChange={(e) => setValue(e.target.value)} options={options} />;
863
+ return <Autocomplete value={value ?? null} onChange={(e) => setValue(e.target.value)} options={options} />;
860
864
  }
861
865
 
862
866
  // Uncontrolled - internal state management
@@ -229,6 +229,33 @@ function BudgetSetting() {
229
229
  }
230
230
  ```
231
231
 
232
+ ## Controlled Empty Value
233
+
234
+ Whether a field is controlled is decided **once, on mount** — the same convention MUI's `useControlled` follows. A field that mounts with `value={undefined}` is treated as uncontrolled for its whole lifetime, so later updates from the parent never reach the screen.
235
+
236
+ Pass `null` to express "controlled, but currently empty":
237
+
238
+ ```tsx
239
+ import { useState } from 'react';
240
+ import { Button, CurrencyInput, Stack } from '@ceed/cds';
241
+
242
+ function AmountField() {
243
+ const [amount, setAmount] = useState<number | null>(null);
244
+
245
+ return (
246
+ <Stack gap={2}>
247
+ <CurrencyInput label="Amount" value={amount} onChange={(e) => setAmount(e.target.value)} />
248
+ {/* Clears the input on screen, not just in state. */}
249
+ <Button onClick={() => setAmount(null)}>Reset</Button>
250
+ </Stack>
251
+ );
252
+ }
253
+ ```
254
+
255
+ Form libraries are where this bites. When something outside the input empties the state — `react-hook-form`'s `setValue('amount', undefined)`, for instance — `value={field.value ?? null}` is what carries that back to the screen.
256
+
257
+ `onChange` reports an empty field as `null` too, so the value it hands you can go straight back into `value` without a conversion step. Typing the state as `number | null` and seeding it with `null` is the shape that needs no `??` anywhere.
258
+
232
259
  ## Best Practices
233
260
 
234
261
  1. **Choose the right currency**: Always set the `currency` prop explicitly. USD uses 2 decimal places while KRW uses none.
@@ -254,23 +281,25 @@ function BudgetSetting() {
254
281
 
255
282
  ### Key Props
256
283
 
257
- | Prop | Type | Default | Description |
258
- | -------------- | -------------------------------------------------------- | ------- | ------------------------------------------------------------------------ |
259
- | `value` | `number` | - | Currency value (controlled mode) |
260
- | `defaultValue` | `number` | - | Initial currency value (uncontrolled mode) |
261
- | `onChange` | `(event: { target: { name?, value?: number } }) => void` | - | Callback when the value changes |
262
- | `currency` | `'USD' \| 'KRW' \| 'CAD' \| 'usd' \| 'krw' \| 'cad'` | `'USD'` | Currency code (uppercase or lowercase; determines symbol and formatting) |
263
- | `useMinorUnit` | `boolean` | `false` | When true, value is in minor units (e.g., cents for USD) |
264
- | `max` | `number` | - | Maximum allowed value |
265
- | `label` | `ReactNode` | - | Form label displayed above the input |
266
- | `helperText` | `ReactNode` | - | Helper text displayed below the input |
267
- | `error` | `boolean` | `false` | Applies danger color to indicate validation error |
268
- | `required` | `boolean` | `false` | Marks the field as required |
269
- | `disabled` | `boolean` | `false` | Disables the input |
270
- | `name` | `string` | - | HTML name attribute for form submission |
271
- | `placeholder` | `string` | - | Placeholder text when the input is empty |
272
- | `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Input size |
273
- | `sx` | `SxProps` | - | Custom styles using the MUI system |
284
+ | Prop | Type | Default | Description |
285
+ | -------------- | ---------------------------------------------------------------- | ------- | ------------------------------------------------------------------------ |
286
+ | `value` | `number \| null` | - | Currency value (controlled mode) |
287
+ | `defaultValue` | `number` | - | Initial currency value (uncontrolled mode) |
288
+ | `onChange` | `(event: { target: { name?, value?: number \| null } }) => void` | - | Callback when the value changes |
289
+ | `currency` | `'USD' \| 'KRW' \| 'CAD' \| 'usd' \| 'krw' \| 'cad'` | `'USD'` | Currency code (uppercase or lowercase; determines symbol and formatting) |
290
+ | `useMinorUnit` | `boolean` | `false` | When true, value is in minor units (e.g., cents for USD) |
291
+ | `max` | `number` | - | Maximum allowed value |
292
+ | `label` | `ReactNode` | - | Form label displayed above the input |
293
+ | `helperText` | `ReactNode` | - | Helper text displayed below the input |
294
+ | `error` | `boolean` | `false` | Applies danger color to indicate validation error |
295
+ | `required` | `boolean` | `false` | Marks the field as required |
296
+ | `disabled` | `boolean` | `false` | Disables the input |
297
+ | `name` | `string` | - | HTML name attribute for form submission |
298
+ | `placeholder` | `string` | - | Placeholder text when the input is empty |
299
+ | `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Input size |
300
+ | `sx` | `SxProps` | - | Custom styles using the MUI system |
301
+
302
+ > **Note**: For a **controlled** field that starts empty, pass `null` as `value`. Passing `undefined` makes the field uncontrolled from the first render, so later external updates (including resetting it to an empty value) are not reflected on screen.
274
303
 
275
304
  ## Accessibility
276
305
 
@@ -603,8 +603,8 @@ function RegionalDateField({ locale }: { locale: string }) {
603
603
 
604
604
  | Prop | Type | Default | Description |
605
605
  | ------------------- | ------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
606
- | `value` | `string` | - | Selected date string in `format` (controlled mode) |
607
- | `defaultValue` | `string` | `''` | Initial date string (uncontrolled mode) |
606
+ | `value` | `string \| null` | - | Selected date string in `format` (controlled mode) |
607
+ | `defaultValue` | `string` | - | Initial date string (uncontrolled mode) |
608
608
  | `onChange` | `(event: { target: { name?, value: string } }) => void` | - | Callback when the date changes. Value is in `format` |
609
609
  | `format` | `string` | `'YYYY/MM/DD'` | Format of the `value` and `onChange` value. Determines the internal data format |
610
610
  | `displayFormat` | `string` | `'YYYY/MM/DD'` | Format displayed in the input field. Can differ from `format` for locale display. Supports `MMM` (short month name) and `MMMM` (full month name) tokens |
@@ -623,6 +623,8 @@ function RegionalDateField({ locale }: { locale: string }) {
623
623
  | `inputReadOnly` | `boolean` | `false` | Prevents keyboard typing in the input; users select via the calendar, which opens on clicking either the input or the calendar button |
624
624
  | `hideClearButton` | `boolean` | `false` | Hides the clear button in the calendar popup |
625
625
 
626
+ > **Note**: For a **controlled** field that starts empty, pass `null` as `value`. Passing `undefined` makes the field uncontrolled from the first render, so later external updates (including resetting it to an empty value) are not reflected on screen.
627
+
626
628
  ### format vs displayFormat
627
629
 
628
630
  | Prop | Controls | Example |
@@ -725,8 +725,8 @@ const getDuration = (value) => {
725
725
 
726
726
  | Prop | Type | Default | Description |
727
727
  | ----------------- | ------------------------------------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------- |
728
- | `value` | `string` | - | Selected date range string in `format` (controlled mode) |
729
- | `defaultValue` | `string` | `''` | Initial date range string (uncontrolled mode) |
728
+ | `value` | `string \| null` | - | Selected date range string in `format` (controlled mode) |
729
+ | `defaultValue` | `string` | - | Initial date range string (uncontrolled mode) |
730
730
  | `onChange` | `(event: { target: { name?, value: string } }) => void` | - | Callback when the date range changes |
731
731
  | `format` | `string` | `'YYYY/MM/DD'` | Format of the `value` and `onChange` value |
732
732
  | `displayFormat` | `string` | `'YYYY/MM/DD'` | Format displayed in the input field. Supports `MMM` (short month name) and `MMMM` (full month name) tokens |
@@ -745,6 +745,8 @@ const getDuration = (value) => {
745
745
  | `hideClearButton` | `boolean` | `false` | Hides the clear button in the calendar popup |
746
746
  | `numberOfMonths` | `number` | `1` | Number of calendar months to show side by side in the popup |
747
747
 
748
+ > **Note**: For a **controlled** field that starts empty, pass `null` as `value`. Passing `undefined` makes the field uncontrolled from the first render, so later external updates (including resetting it to an empty value) are not reflected on screen.
749
+
748
750
  ### Alphabetic Month Tokens (MMM / MMMM)
749
751
 
750
752
  The `displayFormat` prop supports alphabetic month tokens for more human-readable date display:
@@ -377,7 +377,7 @@ When dealing with a large set of options like countries, the search filter and v
377
377
  | Prop | Type | Default | Description |
378
378
  | ------------- | -------------------------------------------------------- | ---------- | --------------------------------------------------- |
379
379
  | `options` | `{ value: string; label: string; disabled?: boolean }[]` | (required) | Array of checkbox options |
380
- | `value` | `string[]` | - | Selected values (controlled mode) |
380
+ | `value` | `string[] \| null` | - | Selected values (controlled mode) |
381
381
  | `onChange` | `(value: string[]) => void` | - | Callback when selection changes |
382
382
  | `label` | `ReactNode` | - | Group label displayed above the component |
383
383
  | `helperText` | `ReactNode` | - | Helper text displayed below the component |
@@ -387,6 +387,8 @@ When dealing with a large set of options like countries, the search filter and v
387
387
  | `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Component size |
388
388
  | `maxHeight` | `string \| number` | `300` | Maximum height of the options list before scrolling |
389
389
 
390
+ > **Note**: For a **controlled** field that starts empty, pass `null` as `value`. Passing `undefined` makes the field uncontrolled from the first render, so later external updates (including resetting it to an empty value) are not reflected on screen.
391
+
390
392
  ## Accessibility
391
393
 
392
394
  - The search input is labeled by the component's `label` prop, ensuring screen readers announce its purpose.
@@ -416,6 +416,33 @@ function ValidatedForm() {
416
416
  }
417
417
  ```
418
418
 
419
+ ## Controlled Empty Value
420
+
421
+ Whether a field is controlled is decided **once, on mount** — the same convention MUI's `useControlled` follows. A field that mounts with `value={undefined}` is treated as uncontrolled for its whole lifetime, so later updates from the parent never reach the screen.
422
+
423
+ Pass `null` to express "controlled, but currently empty":
424
+
425
+ ```tsx
426
+ import { useState } from 'react';
427
+ import { Button, Input } from '@ceed/cds';
428
+
429
+ function NameField() {
430
+ const [name, setName] = useState<string | undefined>(undefined);
431
+
432
+ return (
433
+ <>
434
+ <Input label="Name" value={name ?? null} onChange={(e) => setName(e.target.value)} />
435
+ {/* Clears the input on screen, not just in state. */}
436
+ <Button onClick={() => setName(undefined)}>Reset</Button>
437
+ </>
438
+ );
439
+ }
440
+ ```
441
+
442
+ Form libraries are where this bites. When something outside the input empties the state — `react-hook-form`'s `setValue('name', undefined)`, for instance — `value={field.value ?? null}` is what carries that back to the screen.
443
+
444
+ `onChange` never emits `null` or `undefined`: `Input` reports an empty field as `''`. Only the `value` prop accepts `null`.
445
+
419
446
  ## Props and Customization
420
447
 
421
448
  ### Key Props
@@ -427,9 +454,9 @@ function ValidatedForm() {
427
454
  | `error` | `boolean` | `false` | Applies danger color to indicate validation error |
428
455
  | `enableClearable` | `boolean` | `false` | Shows a clear button when the input has a value |
429
456
  | `disableTogglePasswordButton` | `boolean` | `false` | Hides the password visibility toggle (only applies when `type="password"`) |
430
- | `value` | `string` | - | Input value for controlled mode |
431
- | `defaultValue` | `string` | - | Initial value for uncontrolled mode |
432
- | `onChange` | `(event: ChangeEvent<HTMLInputElement>) => void` | - | Callback when the input value changes |
457
+ | `value` | `string \| number \| readonly string[] \| null` | - | Input value for controlled mode |
458
+ | `defaultValue` | `string \| number \| readonly string[]` | - | Initial value for uncontrolled mode |
459
+ | `onChange` | `(event: { target: { name: string; value: string } }) => void` | - | Callback when the input value changes |
433
460
  | `placeholder` | `string` | - | Placeholder text when the input is empty |
434
461
  | `type` | `string` | `'text'` | HTML input type (`text`, `password`, `email`, `number`, etc.) |
435
462
  | `disabled` | `boolean` | `false` | Disables the input |
@@ -443,6 +470,8 @@ function ValidatedForm() {
443
470
  | `endDecorator` | `ReactNode` | - | Content rendered at the end of the input (not supported with `type="password"`) |
444
471
  | `sx` | `SxProps` | - | Custom styles using the MUI system |
445
472
 
473
+ > **Note**: For a **controlled** field that starts empty, pass `null` as `value`. Passing `undefined` makes the field uncontrolled from the first render, so later external updates (including resetting it to an empty value) are not reflected on screen.
474
+
446
475
  > **Note**: Input also accepts all Joy UI Input props and Framer Motion props.
447
476
 
448
477
  ## Best Practices
@@ -477,8 +477,8 @@ const handleChange = (e) => {
477
477
 
478
478
  | Prop | Type | Default | Description |
479
479
  | --------------- | ------------------------------------------------------- | -------------- | ---------------------------------------------------------------------------------------------------------- |
480
- | `value` | `string` | - | Selected month string in `format` (controlled mode) |
481
- | `defaultValue` | `string` | `''` | Initial month string (uncontrolled mode) |
480
+ | `value` | `string \| null` | - | Selected month string in `format` (controlled mode) |
481
+ | `defaultValue` | `string` | - | Initial month string (uncontrolled mode) |
482
482
  | `onChange` | `(event: { target: { name?, value: string } }) => void` | - | Callback when the month changes |
483
483
  | `format` | `string` | `'YYYY/MM/DD'` | Format of the `value` and `onChange` value |
484
484
  | `displayFormat` | `string` | `'YYYY/MM'` | Format displayed in the input field. Supports `MMM` (short month name) and `MMMM` (full month name) tokens |
@@ -494,6 +494,8 @@ const handleChange = (e) => {
494
494
  | `disablePast` | `boolean` | `false` | Disables all past months |
495
495
  | `locale` | `string` | `'default'` | Locale for month names (BCP 47 format) |
496
496
 
497
+ > **Note**: For a **controlled** field that starts empty, pass `null` as `value`. Passing `undefined` makes the field uncontrolled from the first render, so later external updates (including resetting it to an empty value) are not reflected on screen.
498
+
497
499
  ### Alphabetic Month Tokens (MMM / MMMM)
498
500
 
499
501
  The `displayFormat` prop supports alphabetic month tokens for more human-readable month display:
@@ -391,8 +391,8 @@ const handleSubmit = () => {
391
391
 
392
392
  | Prop | Type | Default | Description |
393
393
  | --------------- | ------------------------------------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------- |
394
- | `value` | `string` | - | Selected month range string in `format` (controlled mode) |
395
- | `defaultValue` | `string` | `''` | Initial month range string (uncontrolled mode) |
394
+ | `value` | `string \| null` | - | Selected month range string in `format` (controlled mode) |
395
+ | `defaultValue` | `string` | - | Initial month range string (uncontrolled mode) |
396
396
  | `onChange` | `(event: { target: { name?, value: string } }) => void` | - | Callback when the month range changes |
397
397
  | `format` | `string` | `'YYYY/MM'` | Format of the `value` and `onChange` value |
398
398
  | `displayFormat` | `string` | same as `format` | Format displayed in the input field. Supports `MMM` (short month name) and `MMMM` (full month name) tokens |
@@ -408,6 +408,8 @@ const handleSubmit = () => {
408
408
  | `disableFuture` | `boolean` | `false` | Disables all future months |
409
409
  | `disablePast` | `boolean` | `false` | Disables all past months |
410
410
 
411
+ > **Note**: For a **controlled** field that starts empty, pass `null` as `value`. Passing `undefined` makes the field uncontrolled from the first render, so later external updates (including resetting it to an empty value) are not reflected on screen.
412
+
411
413
  ### Alphabetic Month Tokens (MMM / MMMM)
412
414
 
413
415
  The `displayFormat` prop supports alphabetic month tokens for more human-readable month display: