@ceed/ads 1.40.0 → 1.41.0

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;
@@ -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
  );
@@ -780,36 +781,38 @@ function LazyAutocomplete({ fetchOptions }) {
780
781
 
781
782
  ### Key Props
782
783
 
783
- | Prop | Type | Default | Description |
784
- | ----------------------- | -------------------------------------------------------------- | -------------- | ----------------------------------------------------- |
785
- | `options` | `string[] \| OptionObject[]` | `[]` | Array of options (strings or objects) |
786
- | `value` | `string \| string[]` | - | Selected value(s) for controlled mode |
787
- | `defaultValue` | `string \| string[]` | - | Initial value for uncontrolled mode |
788
- | `onChange` | `(event: { target: { value } }) => void` | - | Callback when selection changes |
789
- | `onInputChange` | `(event: { target: { value } }) => void` | - | Callback when input text changes |
790
- | `label` | `ReactNode` | - | Label text above the input |
791
- | `placeholder` | `string` | - | Placeholder text when empty |
792
- | `loading` | `boolean` | `false` | Show loading indicator |
793
- | `multiple` | `boolean` | `false` | Allow multiple selections |
794
- | `groupBy` | `(option) => string` | - | Function to group options |
795
- | `size` | `'sm' \| 'md' \| 'lg'` | `'sm'` | Input size |
796
- | `variant` | `'outlined' \| 'soft' \| 'solid' \| 'plain'` | `'outlined'` | Visual style variant |
797
- | `color` | `'primary' \| 'neutral' \| 'danger' \| 'success' \| 'warning'` | `'neutral'` | Color scheme |
798
- | `disabled` | `boolean` | `false` | Disable the input |
799
- | `readOnly` | `boolean` | `false` | Make the input read-only (focusable but not editable) |
800
- | `required` | `boolean` | `false` | Mark the field as required |
801
- | `error` | `boolean` | `false` | Indicate an error state |
802
- | `helperText` | `ReactNode` | - | Helper text below the input |
803
- | `freeSolo` | `boolean` | `false` | Allow arbitrary values not in the options list |
804
- | `disableClearable` | `boolean` | `false` | Hide the clear (X) button |
805
- | `noOptionsText` | `ReactNode` | `'No options'` | Text when no options match |
806
- | `loadingText` | `ReactNode` | `'Loading…'` | Text while loading |
807
- | `limitTags` | `number` | `-1` | Max visible tags in multiple mode (-1 for unlimited) |
808
- | `autoHighlight` | `boolean` | `false` | Auto-highlight first option |
809
- | `clearOnEscape` | `boolean` | `false` | Clear value on Escape key |
810
- | `disableCloseOnSelect` | `boolean` | `false` | Keep popup open after selection |
811
- | `openOnFocus` | `boolean` | `false` | Open popup on focus |
812
- | `filterSelectedOptions` | `boolean` | `false` | Hide selected options from dropdown (multiple mode) |
784
+ | Prop | Type | Default | Description |
785
+ | ----------------------- | ---------------------------------------------------------------------------- | -------------- | ----------------------------------------------------- |
786
+ | `options` | `string[] \| OptionObject[]` | - | Array of options (strings or objects) |
787
+ | `value` | `string \| string[] \| null` | - | Selected value(s) for controlled mode |
788
+ | `defaultValue` | `string \| string[]` | - | Initial value for uncontrolled mode |
789
+ | `onChange` | `(event: { target: { name?, value?: string \| string[] \| null } }) => void` | - | Callback when selection changes |
790
+ | `onInputChange` | `(event: { target: { value } }) => void` | - | Callback when input text changes |
791
+ | `label` | `ReactNode` | - | Label text above the input |
792
+ | `placeholder` | `string` | - | Placeholder text when empty |
793
+ | `loading` | `boolean` | `false` | Show loading indicator |
794
+ | `multiple` | `boolean` | `false` | Allow multiple selections |
795
+ | `groupBy` | `(option) => string` | - | Function to group options |
796
+ | `size` | `'sm' \| 'md' \| 'lg'` | `'sm'` | Input size |
797
+ | `variant` | `'outlined' \| 'soft' \| 'solid' \| 'plain'` | `'outlined'` | Visual style variant |
798
+ | `color` | `'primary' \| 'neutral' \| 'danger' \| 'success' \| 'warning'` | `'neutral'` | Color scheme |
799
+ | `disabled` | `boolean` | `false` | Disable the input |
800
+ | `readOnly` | `boolean` | `false` | Make the input read-only (focusable but not editable) |
801
+ | `required` | `boolean` | `false` | Mark the field as required |
802
+ | `error` | `boolean` | `false` | Indicate an error state |
803
+ | `helperText` | `ReactNode` | - | Helper text below the input |
804
+ | `freeSolo` | `boolean` | `false` | Allow arbitrary values not in the options list |
805
+ | `disableClearable` | `boolean` | `false` | Hide the clear (X) button |
806
+ | `noOptionsText` | `ReactNode` | `'No options'` | Text when no options match |
807
+ | `loadingText` | `ReactNode` | `'Loading…'` | Text while loading |
808
+ | `limitTags` | `number` | `-1` | Max visible tags in multiple mode (-1 for unlimited) |
809
+ | `autoHighlight` | `boolean` | `false` | Auto-highlight first option |
810
+ | `clearOnEscape` | `boolean` | `false` | Clear value on Escape key |
811
+ | `disableCloseOnSelect` | `boolean` | `false` | Keep popup open after selection |
812
+ | `openOnFocus` | `boolean` | `false` | Open popup on focus |
813
+ | `filterSelectedOptions` | `boolean` | `false` | Hide selected options from dropdown (multiple mode) |
814
+
815
+ > **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.
813
816
 
814
817
  ### Option Object Structure
815
818
 
@@ -850,11 +853,12 @@ const options = [
850
853
  ### Controlled vs Uncontrolled
851
854
 
852
855
  ```tsx
853
- // Controlled - manage value externally
856
+ // Controlled - manage value externally.
857
+ // NOTE: pass `null`, not `undefined`, while the field is empty - see the note under Props.
854
858
  function ControlledExample() {
855
859
  const [value, setValue] = useState<string | undefined>();
856
860
 
857
- return <Autocomplete value={value} onChange={(e) => setValue(e.target.value)} options={options} />;
861
+ return <Autocomplete value={value ?? null} onChange={(e) => setValue(e.target.value)} options={options} />;
858
862
  }
859
863
 
860
864
  // Uncontrolled - internal state management
@@ -869,7 +873,7 @@ function UncontrolledExample() {
869
873
  // Small
870
874
  <Autocomplete options={options} size="sm" />
871
875
 
872
- // Medium (default)
876
+ // Medium
873
877
  <Autocomplete options={options} size="md" />
874
878
 
875
879
  // Large
@@ -227,6 +227,33 @@ function BudgetSetting() {
227
227
  }
228
228
  ```
229
229
 
230
+ ## Controlled Empty Value
231
+
232
+ 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.
233
+
234
+ Pass `null` to express "controlled, but currently empty":
235
+
236
+ ```tsx
237
+ import { useState } from 'react';
238
+ import { Button, CurrencyInput, Stack } from '@ceed/ads';
239
+
240
+ function AmountField() {
241
+ const [amount, setAmount] = useState<number | null>(null);
242
+
243
+ return (
244
+ <Stack gap={2}>
245
+ <CurrencyInput label="Amount" value={amount} onChange={(e) => setAmount(e.target.value)} />
246
+ {/* Clears the input on screen, not just in state. */}
247
+ <Button onClick={() => setAmount(null)}>Reset</Button>
248
+ </Stack>
249
+ );
250
+ }
251
+ ```
252
+
253
+ 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.
254
+
255
+ `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.
256
+
230
257
  ## Best Practices
231
258
 
232
259
  1. **Choose the right currency**: Always set the `currency` prop explicitly. USD uses 2 decimal places while KRW uses none.
@@ -252,23 +279,25 @@ function BudgetSetting() {
252
279
 
253
280
  ### Key Props
254
281
 
255
- | Prop | Type | Default | Description |
256
- | -------------- | -------------------------------------------------------- | ------- | ------------------------------------------------------------------------ |
257
- | `value` | `number` | - | Currency value (controlled mode) |
258
- | `defaultValue` | `number` | - | Initial currency value (uncontrolled mode) |
259
- | `onChange` | `(event: { target: { name?, value?: number } }) => void` | - | Callback when the value changes |
260
- | `currency` | `'USD' \| 'KRW' \| 'CAD' \| 'usd' \| 'krw' \| 'cad'` | `'USD'` | Currency code (uppercase or lowercase; determines symbol and formatting) |
261
- | `useMinorUnit` | `boolean` | `false` | When true, value is in minor units (e.g., cents for USD) |
262
- | `max` | `number` | - | Maximum allowed value |
263
- | `label` | `ReactNode` | - | Form label displayed above the input |
264
- | `helperText` | `ReactNode` | - | Helper text displayed below the input |
265
- | `error` | `boolean` | `false` | Applies danger color to indicate validation error |
266
- | `required` | `boolean` | `false` | Marks the field as required |
267
- | `disabled` | `boolean` | `false` | Disables the input |
268
- | `name` | `string` | - | HTML name attribute for form submission |
269
- | `placeholder` | `string` | - | Placeholder text when the input is empty |
270
- | `size` | `'sm' \| 'md' \| 'lg'` | `'sm'` | Input size |
271
- | `sx` | `SxProps` | - | Custom styles using the MUI system |
282
+ | Prop | Type | Default | Description |
283
+ | -------------- | ---------------------------------------------------------------- | ------- | ------------------------------------------------------------------------ |
284
+ | `value` | `number \| null` | - | Currency value (controlled mode) |
285
+ | `defaultValue` | `number` | - | Initial currency value (uncontrolled mode) |
286
+ | `onChange` | `(event: { target: { name?, value?: number \| null } }) => void` | - | Callback when the value changes |
287
+ | `currency` | `'USD' \| 'KRW' \| 'CAD' \| 'usd' \| 'krw' \| 'cad'` | `'USD'` | Currency code (uppercase or lowercase; determines symbol and formatting) |
288
+ | `useMinorUnit` | `boolean` | `false` | When true, value is in minor units (e.g., cents for USD) |
289
+ | `max` | `number` | - | Maximum allowed value |
290
+ | `label` | `ReactNode` | - | Form label displayed above the input |
291
+ | `helperText` | `ReactNode` | - | Helper text displayed below the input |
292
+ | `error` | `boolean` | `false` | Applies danger color to indicate validation error |
293
+ | `required` | `boolean` | `false` | Marks the field as required |
294
+ | `disabled` | `boolean` | `false` | Disables the input |
295
+ | `name` | `string` | - | HTML name attribute for form submission |
296
+ | `placeholder` | `string` | - | Placeholder text when the input is empty |
297
+ | `size` | `'sm' \| 'md' \| 'lg'` | `'sm'` | Input size |
298
+ | `sx` | `SxProps` | - | Custom styles using the MUI system |
299
+
300
+ > **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.
272
301
 
273
302
  ## Accessibility
274
303
 
@@ -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:
@@ -375,7 +375,7 @@ When dealing with a large set of options like countries, the search filter and v
375
375
  | Prop | Type | Default | Description |
376
376
  | ------------- | -------------------------------------------------------- | ---------- | --------------------------------------------------- |
377
377
  | `options` | `{ value: string; label: string; disabled?: boolean }[]` | (required) | Array of checkbox options |
378
- | `value` | `string[]` | - | Selected values (controlled mode) |
378
+ | `value` | `string[] \| null` | - | Selected values (controlled mode) |
379
379
  | `onChange` | `(value: string[]) => void` | - | Callback when selection changes |
380
380
  | `label` | `ReactNode` | - | Group label displayed above the component |
381
381
  | `helperText` | `ReactNode` | - | Helper text displayed below the component |
@@ -385,6 +385,8 @@ When dealing with a large set of options like countries, the search filter and v
385
385
  | `size` | `'sm' \| 'md' \| 'lg'` | `'sm'` | Component size |
386
386
  | `maxHeight` | `string \| number` | `300` | Maximum height of the options list before scrolling |
387
387
 
388
+ > **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.
389
+
388
390
  ## Accessibility
389
391
 
390
392
  - The search input is labeled by the component's `label` prop, ensuring screen readers announce its purpose.
@@ -414,6 +414,33 @@ function ValidatedForm() {
414
414
  }
415
415
  ```
416
416
 
417
+ ## Controlled Empty Value
418
+
419
+ 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.
420
+
421
+ Pass `null` to express "controlled, but currently empty":
422
+
423
+ ```tsx
424
+ import { useState } from 'react';
425
+ import { Button, Input } from '@ceed/ads';
426
+
427
+ function NameField() {
428
+ const [name, setName] = useState<string | undefined>(undefined);
429
+
430
+ return (
431
+ <>
432
+ <Input label="Name" value={name ?? null} onChange={(e) => setName(e.target.value)} />
433
+ {/* Clears the input on screen, not just in state. */}
434
+ <Button onClick={() => setName(undefined)}>Reset</Button>
435
+ </>
436
+ );
437
+ }
438
+ ```
439
+
440
+ 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.
441
+
442
+ `onChange` never emits `null` or `undefined`: `Input` reports an empty field as `''`. Only the `value` prop accepts `null`.
443
+
417
444
  ## Props and Customization
418
445
 
419
446
  ### Key Props
@@ -425,9 +452,9 @@ function ValidatedForm() {
425
452
  | `error` | `boolean` | `false` | Applies danger color to indicate validation error |
426
453
  | `enableClearable` | `boolean` | `false` | Shows a clear button when the input has a value |
427
454
  | `disableTogglePasswordButton` | `boolean` | `false` | Hides the password visibility toggle (only applies when `type="password"`) |
428
- | `value` | `string` | - | Input value for controlled mode |
429
- | `defaultValue` | `string` | - | Initial value for uncontrolled mode |
430
- | `onChange` | `(event: ChangeEvent<HTMLInputElement>) => void` | - | Callback when the input value changes |
455
+ | `value` | `string \| number \| readonly string[] \| null` | - | Input value for controlled mode |
456
+ | `defaultValue` | `string \| number \| readonly string[]` | - | Initial value for uncontrolled mode |
457
+ | `onChange` | `(event: { target: { name: string; value: string } }) => void` | - | Callback when the input value changes |
431
458
  | `placeholder` | `string` | - | Placeholder text when the input is empty |
432
459
  | `type` | `string` | `'text'` | HTML input type (`text`, `password`, `email`, `number`, etc.) |
433
460
  | `disabled` | `boolean` | `false` | Disables the input |
@@ -441,6 +468,8 @@ function ValidatedForm() {
441
468
  | `endDecorator` | `ReactNode` | - | Content rendered at the end of the input (not supported with `type="password"`) |
442
469
  | `sx` | `SxProps` | - | Custom styles using the MUI system |
443
470
 
471
+ > **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.
472
+
444
473
  > **Note**: Input also accepts all Joy UI Input props and Framer Motion props.
445
474
 
446
475
  ## 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:
@@ -263,24 +263,26 @@ function CompletionTracker({ value, onChange }) {
263
263
 
264
264
  ### Key Props
265
265
 
266
- | Prop | Type | Default | Description |
267
- | ----------------- | -------------------------------------------------------- | ------- | ------------------------------------------------------ |
268
- | `value` | `number` | - | Percentage value (controlled mode) |
269
- | `defaultValue` | `number` | - | Initial percentage value (uncontrolled mode) |
270
- | `onChange` | `(event: { target: { name?, value?: number } }) => void` | - | Callback when the value changes |
271
- | `useMinorUnit` | `boolean` | `false` | When true, value is in basis points (e.g., 1000 = 10%) |
272
- | `maxDecimalScale` | `number` | `0` | Maximum number of decimal places |
273
- | `min` | `number` | - | Minimum allowed percentage value |
274
- | `max` | `number` | - | Maximum allowed percentage value |
275
- | `label` | `ReactNode` | - | Form label displayed above the input |
276
- | `helperText` | `ReactNode` | - | Helper text displayed below the input |
277
- | `error` | `boolean` | `false` | Applies danger color to indicate validation error |
278
- | `required` | `boolean` | `false` | Marks the field as required |
279
- | `disabled` | `boolean` | `false` | Disables the input |
280
- | `name` | `string` | - | HTML name attribute for form submission |
281
- | `placeholder` | `string` | - | Placeholder text when the input is empty |
282
- | `size` | `'sm' \| 'md' \| 'lg'` | `'sm'` | Input size |
283
- | `sx` | `SxProps` | - | Custom styles using the MUI system |
266
+ | Prop | Type | Default | Description |
267
+ | ----------------- | ---------------------------------------------------------------- | ------- | ------------------------------------------------------ |
268
+ | `value` | `number \| null` | - | Percentage value (controlled mode) |
269
+ | `defaultValue` | `number` | - | Initial percentage value (uncontrolled mode) |
270
+ | `onChange` | `(event: { target: { name?, value?: number \| null } }) => void` | - | Callback when the value changes |
271
+ | `useMinorUnit` | `boolean` | `false` | When true, value is in basis points (e.g., 1000 = 10%) |
272
+ | `maxDecimalScale` | `number` | `0` | Maximum number of decimal places |
273
+ | `min` | `number` | - | Minimum allowed percentage value |
274
+ | `max` | `number` | - | Maximum allowed percentage value |
275
+ | `label` | `ReactNode` | - | Form label displayed above the input |
276
+ | `helperText` | `ReactNode` | - | Helper text displayed below the input |
277
+ | `error` | `boolean` | `false` | Applies danger color to indicate validation error |
278
+ | `required` | `boolean` | `false` | Marks the field as required |
279
+ | `disabled` | `boolean` | `false` | Disables the input |
280
+ | `name` | `string` | - | HTML name attribute for form submission |
281
+ | `placeholder` | `string` | - | Placeholder text when the input is empty |
282
+ | `size` | `'sm' \| 'md' \| 'lg'` | `'sm'` | Input size |
283
+ | `sx` | `SxProps` | - | Custom styles using the MUI system |
284
+
285
+ > **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.
284
286
 
285
287
  > **Note**: PercentageInput also accepts all Input props and Framer Motion props.
286
288
 
@@ -1 +1,14 @@
1
+ /**
2
+ * controlled / uncontrolled를 함께 지원하는 상태 훅.
3
+ *
4
+ * NOTE: controlled 여부는 MUI의 `useControlled`와 동일하게 **마운트 시점에 한 번만** 판정한다.
5
+ * 따라서 "아직 값이 비어 있는 controlled 필드"는 `undefined`가 아니라 `null`을 넘겨야 한다.
6
+ * `undefined`로 시작하면 그 필드는 영구히 uncontrolled로 동작해서, 이후 부모가 값을 바꾸거나
7
+ * 비워도 화면이 따라오지 않는다.
8
+ *
9
+ * @example
10
+ * // 빈 값으로 시작하는 controlled 필드
11
+ * const [amount, setAmount] = useState<number | undefined>(undefined);
12
+ * <CurrencyInput value={amount ?? null} onChange={(e) => setAmount(e.target.value)} />
13
+ */
1
14
  export declare function useControlledState<T>(controlledValue: T | undefined, defaultValue: T, onChange?: (value: T) => void): [T, (value: T | ((prev: T) => T)) => void, boolean];