@jobber/components 6.103.3 → 6.103.4-uncontroll-951d07b.6

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.
@@ -231,18 +231,26 @@ interface AutocompleteRebuiltBaseProps<Value extends OptionLike, Multiple extend
231
231
  */
232
232
  readonly multiple?: Multiple;
233
233
  /**
234
- * The currently selected value of the Autocomplete.
235
- * Single-select: undefined indicates no selection
234
+ * The currently selected value of the Autocomplete (controlled mode).
235
+ * Single-select: undefined indicates no selection.
236
+ * If omitted, the component manages its own value state (uncontrolled mode).
236
237
  */
237
- readonly value: AutocompleteValue<Value, Multiple>;
238
+ readonly value?: AutocompleteValue<Value, Multiple>;
238
239
  /**
239
- * The current input value of the Autocomplete.
240
+ * The initial value for uncontrolled mode.
241
+ * Only used when value is not provided.
240
242
  */
241
- readonly inputValue: string;
243
+ readonly defaultValue?: AutocompleteValue<Value, Multiple>;
244
+ /**
245
+ * The current input value of the Autocomplete (controlled mode).
246
+ * If omitted, the component manages its own input state based on the selected value.
247
+ */
248
+ readonly inputValue?: string;
242
249
  /**
243
250
  * Callback invoked when the input value changes.
251
+ * If omitted, the component manages its own input state based on the selected value.
244
252
  */
245
- readonly onInputChange: (value: string) => void;
253
+ readonly onInputChange?: (value: string) => void;
246
254
  /**
247
255
  * Callback invoked when the input is blurred.
248
256
  */
@@ -454,20 +462,17 @@ interface FreeFormOff<Value extends OptionLike, Multiple extends boolean> {
454
462
  readonly allowFreeForm?: false;
455
463
  /**
456
464
  * Callback invoked when the selection value changes.
465
+ * Optional when value is not provided (uncontrolled mode).
457
466
  */
458
- readonly onChange: (value: AutocompleteValue<Value, Multiple>) => void;
467
+ readonly onChange?: (value: AutocompleteValue<Value, Multiple>) => void;
459
468
  }
460
469
  interface FreeFormOn<Value extends OptionLike, Multiple extends boolean> {
461
470
  /**
462
- * Whether the autocomplete allows free-form input.
463
- * When true, the input value is not restricted to the options * in the menu. Input can be used to create a new value.
464
- * When false, the input value must match an option in the menu.
465
- * Input value will be cleared if no selection is made and
466
471
  * Whether the autocomplete allows free-form input.
467
472
  * When true, the input value is not restricted to the options in the menu. Input can be used to create a new value.
468
473
  * When false, the input value must match an option in the menu.
469
474
  * Input value will be cleared if no selection is made and focus is lost.
470
- * */
475
+ */
471
476
  readonly allowFreeForm: true;
472
477
  /**
473
478
  * Factory used to create a Value from free-form input when committing. Necessary with complex option values. The only value the input can produce is a string.
@@ -484,8 +489,9 @@ interface FreeFormOn<Value extends OptionLike, Multiple extends boolean> {
484
489
  * - The user selects an option with click or enter
485
490
  * - The user types a value that matches an option
486
491
  * - The user types a value that does not match an option and allowFreeForm is true
492
+ * Optional when value is not provided (uncontrolled mode).
487
493
  */
488
- readonly onChange: (value: AutocompleteValue<Value, Multiple>) => void;
494
+ readonly onChange?: (value: AutocompleteValue<Value, Multiple>) => void;
489
495
  }
490
496
  export type ActionOrigin = "menu" | "empty";
491
497
  export type AutocompleteRebuiltProps<Value extends OptionLike = OptionLike, Multiple extends boolean = false, SectionExtra extends object = ExtraProps, ActionExtra extends object = ExtraProps> = AutocompleteRebuiltBaseProps<Value, Multiple, SectionExtra, ActionExtra> & (FreeFormOn<Value, Multiple> | FreeFormOff<Value, Multiple>);
@@ -224,13 +224,50 @@ function useAutocompleteListNav({ navigableCount, shouldResetActiveIndexOnClose,
224
224
  // interactions and state transitions.
225
225
  // eslint-disable-next-line max-statements
226
226
  function useAutocomplete(props) {
227
- const { menu, emptyActions, getOptionLabel: getOptionLabelProp, isOptionEqualToValue, inputValue, onInputChange, value, onChange, multiple, openOnFocus = true, readOnly = false, debounce: debounceMs = 300, } = props;
227
+ const { menu, emptyActions, getOptionLabel: getOptionLabelProp, isOptionEqualToValue, inputValue: inputValueProp, onInputChange: onInputChangeProp, value: valueProp, defaultValue, onChange: onChangeProp, multiple, openOnFocus = true, readOnly = false, debounce: debounceMs = 300, } = props;
228
+ // Internal state for uncontrolled value
229
+ const [internalValue, setInternalValue] = React.useState(defaultValue);
230
+ // Use controlled value if provided, otherwise use internal state
231
+ const value = valueProp !== undefined ? valueProp : internalValue;
232
+ const onChange = onChangeProp !== null && onChangeProp !== void 0 ? onChangeProp : setInternalValue;
228
233
  // TODO: Clean up the types in these refs by enhancing the type system in useCallbackRef
229
234
  const getOptionLabelPropRef = jobberHooks.useCallbackRef((opt) => getOptionLabelProp === null || getOptionLabelProp === void 0 ? void 0 : getOptionLabelProp(opt));
230
235
  const getOptionLabel = React.useCallback((opt) => {
231
236
  const maybe = getOptionLabelPropRef(opt);
232
237
  return maybe !== null && maybe !== void 0 ? maybe : opt.label;
233
238
  }, [getOptionLabelPropRef]);
239
+ // Initialize internal input value from defaultValue
240
+ const [internalInputValue, setInternalInputValue] = React.useState(() => {
241
+ if (multiple)
242
+ return "";
243
+ const initialValue = (defaultValue !== null && defaultValue !== void 0 ? defaultValue : valueProp);
244
+ if (!initialValue)
245
+ return "";
246
+ // Call getOptionLabelProp directly if provided, otherwise use label
247
+ const customLabel = getOptionLabelProp === null || getOptionLabelProp === void 0 ? void 0 : getOptionLabelProp(initialValue);
248
+ return customLabel !== null && customLabel !== void 0 ? customLabel : initialValue.label;
249
+ });
250
+ // Track previous value to detect when it changes
251
+ const prevValueRef = React.useRef(value);
252
+ // Sync internal input value with selected value only when value changes (not on user input)
253
+ React.useEffect(() => {
254
+ const isInputControlled = inputValueProp !== undefined;
255
+ if (isInputControlled || multiple)
256
+ return;
257
+ // Only update if the value actually changed
258
+ if (prevValueRef.current !== value) {
259
+ prevValueRef.current = value;
260
+ const currentValue = value;
261
+ // Mark this as a programmatic change, not user input
262
+ // This prevents onChange from being called when we sync from prop changes
263
+ lastInputWasUser.current = false;
264
+ setInternalInputValue(currentValue ? getOptionLabel(currentValue) : "");
265
+ }
266
+ }, [value, inputValueProp, multiple, getOptionLabel]);
267
+ // Use controlled inputValue if provided, otherwise use internal state
268
+ const isInputControlled = inputValueProp !== undefined;
269
+ const inputValue = isInputControlled ? inputValueProp : internalInputValue;
270
+ const onInputChange = onInputChangeProp !== null && onInputChangeProp !== void 0 ? onInputChangeProp : setInternalInputValue;
234
271
  const isOptionEqualToValueRef = jobberHooks.useCallbackRef((a, b) => isOptionEqualToValue === null || isOptionEqualToValue === void 0 ? void 0 : isOptionEqualToValue(a, b));
235
272
  const equals = React.useCallback((a, b) => {
236
273
  const custom = isOptionEqualToValueRef(a, b);
@@ -379,8 +416,11 @@ function useAutocomplete(props) {
379
416
  // In multiple mode, clearing the input should NOT clear the selection
380
417
  if (multiple)
381
418
  return;
382
- // For single-select, treat clearing input as clearing the selection
383
- if (hasSelection) {
419
+ // Only clear the selection if the user actually cleared the input
420
+ // (not from internal state sync when parent changes the value prop)
421
+ // This prevents calling onChange when we're syncing state from a controlled value prop,
422
+ // but still allows onChange to fire when the user deletes the input text
423
+ if (lastInputWasUser.current && hasSelection) {
384
424
  onChange === null || onChange === void 0 ? void 0 : onChange(undefined);
385
425
  }
386
426
  }, [inputValue, multiple, hasSelection, setActiveIndex, onChange, open]);
@@ -486,7 +526,7 @@ function useAutocomplete(props) {
486
526
  const freeFormCreated = (_a = props.createFreeFormValue) === null || _a === void 0 ? void 0 : _a.call(props, inputText);
487
527
  if (!freeFormCreated)
488
528
  return false;
489
- props.onChange(freeFormCreated);
529
+ onChange(freeFormCreated);
490
530
  return true;
491
531
  }
492
532
  const tryRestoreInputToSelectedLabel = React.useCallback(() => {
@@ -666,6 +706,7 @@ function useAutocomplete(props) {
666
706
  activeIndex,
667
707
  setActiveIndex,
668
708
  listRef,
709
+ inputValue,
669
710
  // actions
670
711
  onSelection,
671
712
  onAction,
@@ -903,8 +944,8 @@ const AutocompleteRebuilt = React.forwardRef(AutocompleteRebuiltInternal);
903
944
  // eslint-disable-next-line max-statements
904
945
  function AutocompleteRebuiltInternal(props, forwardedRef) {
905
946
  var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o;
906
- const { inputValue, placeholder, disabled, error, invalid, description, size: sizeProp, loading = false, } = props;
907
- const { renderable, optionCount, persistentsHeaders, persistentsFooters, headerInteractiveCount, middleNavigableCount, getOptionLabel, isOptionSelected, refs, floatingStyles, context, getReferenceProps, getFloatingProps, getItemProps, activeIndex, open, listRef, onSelection, onAction, onInputChangeFromUser, onInputBlur, onInputFocus, onInputKeyDown, setReferenceElement, } = useAutocomplete(props);
947
+ const { placeholder, disabled, error, invalid, description, size: sizeProp, loading = false, } = props;
948
+ const { renderable, optionCount, persistentsHeaders, persistentsFooters, headerInteractiveCount, middleNavigableCount, getOptionLabel, isOptionSelected, refs, floatingStyles, context, getReferenceProps, getFloatingProps, getItemProps, activeIndex, open, listRef, inputValue, onSelection, onAction, onInputChangeFromUser, onInputBlur, onInputFocus, onInputKeyDown, setReferenceElement, } = useAutocomplete(props);
908
949
  const listboxId = React.useId();
909
950
  // Provides mount/unmount-aware transition styles for the floating element
910
951
  const { isMounted, styles: transitionStyles } = floatingUi_react.useTransitionStyles(context, {
@@ -920,7 +961,7 @@ function AutocompleteRebuiltInternal(props, forwardedRef) {
920
961
  onFocus: onInputFocus,
921
962
  onBlur: onInputBlur,
922
963
  });
923
- const inputProps = Object.assign(Object.assign(Object.assign(Object.assign({ version: 2, value: inputValue, onChange: props.readOnly ? undefined : onInputChangeFromUser }, (props.readOnly ? { onFocus: onInputFocus, onBlur: onInputBlur } : {})), { placeholder,
964
+ const inputProps = Object.assign(Object.assign(Object.assign(Object.assign({ version: 2, value: inputValue !== null && inputValue !== void 0 ? inputValue : "", onChange: props.readOnly ? undefined : onInputChangeFromUser }, (props.readOnly ? { onFocus: onInputFocus, onBlur: onInputBlur } : {})), { placeholder,
924
965
  disabled, readOnly: props.readOnly, error: error !== null && error !== void 0 ? error : undefined, name: props.name, invalid, autoComplete: "off", description, size: sizeProp ? sizeProp : undefined, prefix: props.prefix, suffix: props.suffix }), (props.readOnly ? {} : composedReferenceProps)), { role: "combobox", "aria-autocomplete": "list", "aria-expanded": open ? true : false, "aria-controls": listboxId, "aria-activedescendant": open && activeIndex != null
925
966
  ? `${listboxId}-item-${activeIndex}`
926
967
  : undefined });
@@ -222,13 +222,50 @@ function useAutocompleteListNav({ navigableCount, shouldResetActiveIndexOnClose,
222
222
  // interactions and state transitions.
223
223
  // eslint-disable-next-line max-statements
224
224
  function useAutocomplete(props) {
225
- const { menu, emptyActions, getOptionLabel: getOptionLabelProp, isOptionEqualToValue, inputValue, onInputChange, value, onChange, multiple, openOnFocus = true, readOnly = false, debounce: debounceMs = 300, } = props;
225
+ const { menu, emptyActions, getOptionLabel: getOptionLabelProp, isOptionEqualToValue, inputValue: inputValueProp, onInputChange: onInputChangeProp, value: valueProp, defaultValue, onChange: onChangeProp, multiple, openOnFocus = true, readOnly = false, debounce: debounceMs = 300, } = props;
226
+ // Internal state for uncontrolled value
227
+ const [internalValue, setInternalValue] = useState(defaultValue);
228
+ // Use controlled value if provided, otherwise use internal state
229
+ const value = valueProp !== undefined ? valueProp : internalValue;
230
+ const onChange = onChangeProp !== null && onChangeProp !== void 0 ? onChangeProp : setInternalValue;
226
231
  // TODO: Clean up the types in these refs by enhancing the type system in useCallbackRef
227
232
  const getOptionLabelPropRef = useCallbackRef((opt) => getOptionLabelProp === null || getOptionLabelProp === void 0 ? void 0 : getOptionLabelProp(opt));
228
233
  const getOptionLabel = useCallback((opt) => {
229
234
  const maybe = getOptionLabelPropRef(opt);
230
235
  return maybe !== null && maybe !== void 0 ? maybe : opt.label;
231
236
  }, [getOptionLabelPropRef]);
237
+ // Initialize internal input value from defaultValue
238
+ const [internalInputValue, setInternalInputValue] = useState(() => {
239
+ if (multiple)
240
+ return "";
241
+ const initialValue = (defaultValue !== null && defaultValue !== void 0 ? defaultValue : valueProp);
242
+ if (!initialValue)
243
+ return "";
244
+ // Call getOptionLabelProp directly if provided, otherwise use label
245
+ const customLabel = getOptionLabelProp === null || getOptionLabelProp === void 0 ? void 0 : getOptionLabelProp(initialValue);
246
+ return customLabel !== null && customLabel !== void 0 ? customLabel : initialValue.label;
247
+ });
248
+ // Track previous value to detect when it changes
249
+ const prevValueRef = useRef(value);
250
+ // Sync internal input value with selected value only when value changes (not on user input)
251
+ useEffect(() => {
252
+ const isInputControlled = inputValueProp !== undefined;
253
+ if (isInputControlled || multiple)
254
+ return;
255
+ // Only update if the value actually changed
256
+ if (prevValueRef.current !== value) {
257
+ prevValueRef.current = value;
258
+ const currentValue = value;
259
+ // Mark this as a programmatic change, not user input
260
+ // This prevents onChange from being called when we sync from prop changes
261
+ lastInputWasUser.current = false;
262
+ setInternalInputValue(currentValue ? getOptionLabel(currentValue) : "");
263
+ }
264
+ }, [value, inputValueProp, multiple, getOptionLabel]);
265
+ // Use controlled inputValue if provided, otherwise use internal state
266
+ const isInputControlled = inputValueProp !== undefined;
267
+ const inputValue = isInputControlled ? inputValueProp : internalInputValue;
268
+ const onInputChange = onInputChangeProp !== null && onInputChangeProp !== void 0 ? onInputChangeProp : setInternalInputValue;
232
269
  const isOptionEqualToValueRef = useCallbackRef((a, b) => isOptionEqualToValue === null || isOptionEqualToValue === void 0 ? void 0 : isOptionEqualToValue(a, b));
233
270
  const equals = useCallback((a, b) => {
234
271
  const custom = isOptionEqualToValueRef(a, b);
@@ -377,8 +414,11 @@ function useAutocomplete(props) {
377
414
  // In multiple mode, clearing the input should NOT clear the selection
378
415
  if (multiple)
379
416
  return;
380
- // For single-select, treat clearing input as clearing the selection
381
- if (hasSelection) {
417
+ // Only clear the selection if the user actually cleared the input
418
+ // (not from internal state sync when parent changes the value prop)
419
+ // This prevents calling onChange when we're syncing state from a controlled value prop,
420
+ // but still allows onChange to fire when the user deletes the input text
421
+ if (lastInputWasUser.current && hasSelection) {
382
422
  onChange === null || onChange === void 0 ? void 0 : onChange(undefined);
383
423
  }
384
424
  }, [inputValue, multiple, hasSelection, setActiveIndex, onChange, open]);
@@ -484,7 +524,7 @@ function useAutocomplete(props) {
484
524
  const freeFormCreated = (_a = props.createFreeFormValue) === null || _a === void 0 ? void 0 : _a.call(props, inputText);
485
525
  if (!freeFormCreated)
486
526
  return false;
487
- props.onChange(freeFormCreated);
527
+ onChange(freeFormCreated);
488
528
  return true;
489
529
  }
490
530
  const tryRestoreInputToSelectedLabel = useCallback(() => {
@@ -664,6 +704,7 @@ function useAutocomplete(props) {
664
704
  activeIndex,
665
705
  setActiveIndex,
666
706
  listRef,
707
+ inputValue,
667
708
  // actions
668
709
  onSelection,
669
710
  onAction,
@@ -901,8 +942,8 @@ const AutocompleteRebuilt = forwardRef(AutocompleteRebuiltInternal);
901
942
  // eslint-disable-next-line max-statements
902
943
  function AutocompleteRebuiltInternal(props, forwardedRef) {
903
944
  var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o;
904
- const { inputValue, placeholder, disabled, error, invalid, description, size: sizeProp, loading = false, } = props;
905
- const { renderable, optionCount, persistentsHeaders, persistentsFooters, headerInteractiveCount, middleNavigableCount, getOptionLabel, isOptionSelected, refs, floatingStyles, context, getReferenceProps, getFloatingProps, getItemProps, activeIndex, open, listRef, onSelection, onAction, onInputChangeFromUser, onInputBlur, onInputFocus, onInputKeyDown, setReferenceElement, } = useAutocomplete(props);
945
+ const { placeholder, disabled, error, invalid, description, size: sizeProp, loading = false, } = props;
946
+ const { renderable, optionCount, persistentsHeaders, persistentsFooters, headerInteractiveCount, middleNavigableCount, getOptionLabel, isOptionSelected, refs, floatingStyles, context, getReferenceProps, getFloatingProps, getItemProps, activeIndex, open, listRef, inputValue, onSelection, onAction, onInputChangeFromUser, onInputBlur, onInputFocus, onInputKeyDown, setReferenceElement, } = useAutocomplete(props);
906
947
  const listboxId = React__default.useId();
907
948
  // Provides mount/unmount-aware transition styles for the floating element
908
949
  const { isMounted, styles: transitionStyles } = useTransitionStyles(context, {
@@ -918,7 +959,7 @@ function AutocompleteRebuiltInternal(props, forwardedRef) {
918
959
  onFocus: onInputFocus,
919
960
  onBlur: onInputBlur,
920
961
  });
921
- const inputProps = Object.assign(Object.assign(Object.assign(Object.assign({ version: 2, value: inputValue, onChange: props.readOnly ? undefined : onInputChangeFromUser }, (props.readOnly ? { onFocus: onInputFocus, onBlur: onInputBlur } : {})), { placeholder,
962
+ const inputProps = Object.assign(Object.assign(Object.assign(Object.assign({ version: 2, value: inputValue !== null && inputValue !== void 0 ? inputValue : "", onChange: props.readOnly ? undefined : onInputChangeFromUser }, (props.readOnly ? { onFocus: onInputFocus, onBlur: onInputBlur } : {})), { placeholder,
922
963
  disabled, readOnly: props.readOnly, error: error !== null && error !== void 0 ? error : undefined, name: props.name, invalid, autoComplete: "off", description, size: sizeProp ? sizeProp : undefined, prefix: props.prefix, suffix: props.suffix }), (props.readOnly ? {} : composedReferenceProps)), { role: "combobox", "aria-autocomplete": "list", "aria-expanded": open ? true : false, "aria-controls": listboxId, "aria-activedescendant": open && activeIndex != null
923
964
  ? `${listboxId}-item-${activeIndex}`
924
965
  : undefined });
@@ -36,3 +36,20 @@ export declare function FreeFormWrapper({ initialValue, initialInputValue, onCha
36
36
  readonly inputEqualsOption?: (input: string, option: OptionLike) => boolean;
37
37
  readonly debounce?: number;
38
38
  }): React.JSX.Element;
39
+ export declare function UncontrolledWrapper<T extends OptionLike>({ defaultValue, menu, placeholder, }: {
40
+ readonly defaultValue?: T;
41
+ readonly menu: MenuItem<T>[];
42
+ readonly placeholder?: string;
43
+ }): React.JSX.Element;
44
+ export declare function SemiControlledWrapper<T extends OptionLike>({ initialValue, onChange, menu, placeholder, }: {
45
+ readonly initialValue?: T;
46
+ readonly onChange?: (v: T | undefined) => void;
47
+ readonly menu: MenuItem<T>[];
48
+ readonly placeholder?: string;
49
+ }): React.JSX.Element;
50
+ export declare function ControlledValueWrapper<T extends OptionLike>({ value, onChange, menu, placeholder, }: {
51
+ readonly value: T | undefined;
52
+ readonly onChange: (v: T | undefined) => void;
53
+ readonly menu: MenuItem<T>[];
54
+ readonly placeholder?: string;
55
+ }): React.JSX.Element;
@@ -56,6 +56,7 @@ export declare function useAutocomplete<Value extends OptionLike, Multiple exten
56
56
  activeIndex: number | null;
57
57
  setActiveIndex: (index: number | null) => void;
58
58
  listRef: React.MutableRefObject<(HTMLElement | null)[]>;
59
+ inputValue: string;
59
60
  onSelection: (option: Value) => void;
60
61
  onAction: (action: ActionConfig) => void;
61
62
  onInputChangeFromUser: (val: string) => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jobber/components",
3
- "version": "6.103.3",
3
+ "version": "6.103.4-uncontroll-951d07b.6+951d07b93",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -538,5 +538,5 @@
538
538
  "> 1%",
539
539
  "IE 10"
540
540
  ],
541
- "gitHead": "06b9bc94eb94f53486ddc9c41a978731163a85b5"
541
+ "gitHead": "951d07b936bd3e0cc6dc48c9a4cb98f2a93a838e"
542
542
  }