rsuite 6.1.3 → 6.2.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.
Files changed (66) hide show
  1. package/AutoComplete/styles/index.css +3 -0
  2. package/CHANGELOG.md +27 -0
  3. package/Cascader/styles/index.css +3 -0
  4. package/CheckPicker/styles/index.css +3 -0
  5. package/CheckTree/styles/index.css +3 -0
  6. package/CheckTreePicker/styles/index.css +3 -0
  7. package/DatePicker/styles/index.css +3 -0
  8. package/DateRangePicker/styles/index.css +3 -0
  9. package/InputPicker/styles/index.css +3 -0
  10. package/MultiCascadeTree/styles/index.css +3 -0
  11. package/MultiCascader/styles/index.css +3 -0
  12. package/Pagination/styles/index.css +3 -0
  13. package/SelectPicker/styles/index.css +3 -0
  14. package/TagInput/styles/index.css +3 -0
  15. package/TagPicker/styles/index.css +3 -0
  16. package/TimePicker/styles/index.css +3 -0
  17. package/TimeRangePicker/styles/index.css +3 -0
  18. package/Timeline/styles/index.css +11 -0
  19. package/Timeline/styles/index.scss +13 -0
  20. package/Tree/styles/index.css +3 -0
  21. package/TreePicker/styles/index.css +3 -0
  22. package/Uploader/styles/index.css +3 -0
  23. package/Uploader/styles/index.scss +3 -0
  24. package/cjs/AutoComplete/AutoComplete.d.ts +2 -0
  25. package/cjs/AutoComplete/AutoComplete.js +3 -1
  26. package/cjs/CheckTree/utils.js +2 -1
  27. package/cjs/Form/Form.d.ts +37 -0
  28. package/cjs/Form/Form.js +16 -1
  29. package/cjs/Form/hooks/useFormValidate.d.ts +2 -0
  30. package/cjs/Form/hooks/useFormValidate.js +117 -1
  31. package/cjs/Form/index.d.ts +1 -0
  32. package/cjs/Form/resolvers.d.ts +59 -0
  33. package/cjs/Form/resolvers.js +4 -0
  34. package/cjs/Timeline/Timeline.d.ts +5 -0
  35. package/cjs/Timeline/Timeline.js +13 -6
  36. package/cjs/Tree/hooks/useFlattenTree.js +5 -8
  37. package/cjs/Uploader/Uploader.d.ts +2 -0
  38. package/cjs/Uploader/Uploader.js +48 -2
  39. package/cjs/internals/Picker/PickerIndicator.js +4 -1
  40. package/cjs/toaster/toaster.js +38 -3
  41. package/dist/rsuite-no-reset.css +17 -0
  42. package/dist/rsuite-no-reset.min.css +1 -1
  43. package/dist/rsuite.css +17 -0
  44. package/dist/rsuite.js +9 -9
  45. package/dist/rsuite.min.css +1 -1
  46. package/dist/rsuite.min.js +1 -1
  47. package/dist/rsuite.min.js.map +1 -1
  48. package/esm/AutoComplete/AutoComplete.d.ts +2 -0
  49. package/esm/AutoComplete/AutoComplete.js +3 -1
  50. package/esm/CheckTree/utils.js +2 -1
  51. package/esm/Form/Form.d.ts +37 -0
  52. package/esm/Form/Form.js +16 -1
  53. package/esm/Form/hooks/useFormValidate.d.ts +2 -0
  54. package/esm/Form/hooks/useFormValidate.js +117 -1
  55. package/esm/Form/index.d.ts +1 -0
  56. package/esm/Form/resolvers.d.ts +59 -0
  57. package/esm/Form/resolvers.js +2 -0
  58. package/esm/Timeline/Timeline.d.ts +5 -0
  59. package/esm/Timeline/Timeline.js +13 -6
  60. package/esm/Tree/hooks/useFlattenTree.js +5 -8
  61. package/esm/Uploader/Uploader.d.ts +2 -0
  62. package/esm/Uploader/Uploader.js +48 -2
  63. package/esm/internals/Picker/PickerIndicator.js +4 -1
  64. package/esm/toaster/toaster.js +38 -3
  65. package/internals/Picker/styles/index.scss +3 -0
  66. package/package.json +1 -1
@@ -30,6 +30,8 @@ export interface AutoCompleteProps<T = string> extends FormControlPickerProps<T,
30
30
  onOpen?: () => void;
31
31
  /** Called on close */
32
32
  onClose?: () => void;
33
+ /** Ref to the input element */
34
+ inputRef?: React.Ref<HTMLInputElement>;
33
35
  }
34
36
  /**
35
37
  * Autocomplete function of input field.
@@ -51,6 +51,7 @@ const AutoComplete = forwardRef((props, ref) => {
51
51
  onFocus,
52
52
  onBlur,
53
53
  onMenuFocus,
54
+ inputRef,
54
55
  ...rest
55
56
  } = propsWithDefaults;
56
57
  const datalist = transformData(data);
@@ -204,7 +205,8 @@ const AutoComplete = forwardRef((props, ref) => {
204
205
  onBlur: handleInputBlur,
205
206
  onFocus: handleInputFocus,
206
207
  onChange: handleChange,
207
- onKeyDown: handleKeyDownEvent
208
+ onKeyDown: handleKeyDownEvent,
209
+ inputRef: inputRef
208
210
  })));
209
211
  });
210
212
  AutoComplete.displayName = 'AutoComplete';
@@ -213,7 +213,8 @@ export function getDisabledState(nodes, node, props) {
213
213
  */
214
214
  export function getCheckTreeDefaultValue(value, uncheckableItemValues) {
215
215
  if (Array.isArray(value) && Array.isArray(uncheckableItemValues)) {
216
- return value.filter(v => !uncheckableItemValues.includes(v));
216
+ const filtered = value.filter(v => !uncheckableItemValues.includes(v));
217
+ return filtered.length === value.length ? value : filtered;
217
218
  }
218
219
  return value;
219
220
  }
@@ -3,6 +3,7 @@ import { FormControlComponent } from '../FormControl';
3
3
  import { FormInstance } from './hooks/useFormRef';
4
4
  import { Schema } from 'schema-typed';
5
5
  import type { WithAsProps, CheckTriggerType } from '../internals/types';
6
+ import type { Resolver } from './resolvers';
6
7
  export interface FormProps<V = Record<string, any>, M = any, E = {
7
8
  [P in keyof V]?: M;
8
9
  }> extends WithAsProps, Omit<FormHTMLAttributes<HTMLFormElement>, 'onChange' | 'onSubmit' | 'onError' | 'onReset'> {
@@ -40,6 +41,42 @@ export interface FormProps<V = Record<string, any>, M = any, E = {
40
41
  * @see https://github.com/rsuite/schema-typed
41
42
  */
42
43
  model?: Schema;
44
+ /**
45
+ * A resolver function for integrating third-party validation libraries such as
46
+ * Yup, Zod, AJV, Joi, Valibot, etc.
47
+ *
48
+ * When provided, the `resolver` takes precedence over the `model` prop for
49
+ * form-level validation (`check` / `checkAsync`). Field-level inline `rule`
50
+ * props on `<Form.Control>` components are still respected.
51
+ *
52
+ * The resolver receives the current form values and must return (or resolve to)
53
+ * a `{ errors }` object where each key is a field name and each value is an
54
+ * error message or error object. An empty `errors` object means the form is valid.
55
+ *
56
+ * **Note:** If the resolver is asynchronous, form-level sync validation
57
+ * (`check()`) will return `false` and log a warning. Use `checkAsync()` or
58
+ * rely on the `onSubmit` callback (which always awaits the resolver).
59
+ *
60
+ * @example
61
+ * ```tsx
62
+ * import * as yup from 'yup';
63
+ *
64
+ * const schema = yup.object({ name: yup.string().email().required() });
65
+ * const resolver = async (formValue) => {
66
+ * try {
67
+ * await schema.validate(formValue, { abortEarly: false });
68
+ * return { errors: {} };
69
+ * } catch (e) {
70
+ * const errors = {};
71
+ * e.inner.forEach(err => { if (err.path) errors[err.path] = err.message; });
72
+ * return { errors };
73
+ * }
74
+ * };
75
+ *
76
+ * <Form resolver={resolver} onSubmit={handleSubmit}>…</Form>
77
+ * ```
78
+ */
79
+ resolver?: Resolver<V>;
43
80
  /**
44
81
  * Make the form readonly
45
82
  */
package/esm/Form/Form.js CHANGED
@@ -52,6 +52,7 @@ const Form = forwardRef((props, ref) => {
52
52
  fluid,
53
53
  layout,
54
54
  model: formModel = defaultSchema,
55
+ resolver,
55
56
  readOnly,
56
57
  plaintext,
57
58
  children,
@@ -82,7 +83,8 @@ const Form = forwardRef((props, ref) => {
82
83
  getCombinedModel,
83
84
  onCheck,
84
85
  onError,
85
- nestedField
86
+ nestedField,
87
+ resolver
86
88
  };
87
89
  const {
88
90
  formError,
@@ -98,6 +100,19 @@ const Form = forwardRef((props, ref) => {
98
100
  cleanErrorForField
99
101
  } = useFormValidate(controlledFormError, formValidateProps);
100
102
  const submit = useEventCallback(event => {
103
+ if (resolver) {
104
+ // When a resolver is provided, always use the async validation path so that
105
+ // both sync and async resolvers are handled correctly.
106
+ checkAsync().then(({
107
+ hasError
108
+ }) => {
109
+ if (!hasError) {
110
+ onSubmit?.(formValue, event);
111
+ }
112
+ });
113
+ return;
114
+ }
115
+
101
116
  // Check the form before submitting
102
117
  if (check()) {
103
118
  onSubmit?.(formValue, event);
@@ -1,9 +1,11 @@
1
+ import type { Resolver } from '../resolvers';
1
2
  export interface FormErrorProps {
2
3
  formValue: any;
3
4
  getCombinedModel: () => any;
4
5
  onCheck?: (formError: any) => void;
5
6
  onError?: (formError: any) => void;
6
7
  nestedField?: boolean;
8
+ resolver?: Resolver;
7
9
  }
8
10
  export default function useFormValidate(_formError: any, props: FormErrorProps): {
9
11
  formError: any;
@@ -10,7 +10,8 @@ export default function useFormValidate(_formError, props) {
10
10
  getCombinedModel,
11
11
  onCheck,
12
12
  onError,
13
- nestedField
13
+ nestedField,
14
+ resolver
14
15
  } = props;
15
16
  const [realFormError, setFormError] = useControlled(_formError, {});
16
17
  const checkOptions = {
@@ -19,12 +20,64 @@ export default function useFormValidate(_formError, props) {
19
20
  const realFormErrorRef = useRef(realFormError);
20
21
  realFormErrorRef.current = realFormError;
21
22
 
23
+ /**
24
+ * Returns true when an error value is considered non-empty (i.e. the field has an error).
25
+ */
26
+ const isValidError = error => error !== undefined && error !== null && error !== '';
27
+
28
+ /**
29
+ * Merges resolver errors into the current form error state, removing entries that
30
+ * are no longer invalid according to the latest resolver result.
31
+ */
32
+ const mergeResolverErrors = (current, resolverErrors) => {
33
+ const next = {
34
+ ...current
35
+ };
36
+ Object.keys({
37
+ ...current,
38
+ ...resolverErrors
39
+ }).forEach(key => {
40
+ if (isValidError(resolverErrors[key])) {
41
+ next[key] = resolverErrors[key];
42
+ } else {
43
+ delete next[key];
44
+ }
45
+ });
46
+ return next;
47
+ };
48
+
22
49
  /**
23
50
  * Validate the form data and return a boolean.
24
51
  * The error message after verification is returned in the callback.
52
+ *
53
+ * When a `resolver` is provided and the resolver returns a Promise (async resolver),
54
+ * this method cannot resolve the result synchronously. In that case it returns `false`
55
+ * immediately and you should use `checkAsync()` instead.
25
56
  * @param callback
26
57
  */
27
58
  const check = useEventCallback(callback => {
59
+ if (resolver) {
60
+ const result = resolver(formValue || {});
61
+
62
+ // Async resolver: cannot handle synchronously
63
+ if (result instanceof Promise) {
64
+ if (process.env.NODE_ENV !== 'production') {
65
+ console.warn('[rsuite] The `resolver` provided to <Form> returns a Promise. ' + 'Use `checkAsync()` or rely on `onSubmit` for async validation.');
66
+ }
67
+ return false;
68
+ }
69
+ const {
70
+ errors
71
+ } = result;
72
+ const hasError = Object.keys(errors).length > 0;
73
+ setFormError(errors);
74
+ onCheck?.(errors);
75
+ callback?.(errors);
76
+ if (hasError) {
77
+ onError?.(errors);
78
+ }
79
+ return !hasError;
80
+ }
28
81
  const formError = {};
29
82
  let errorCount = 0;
30
83
  const model = getCombinedModel();
@@ -59,6 +112,35 @@ export default function useFormValidate(_formError, props) {
59
112
  return true;
60
113
  });
61
114
  const checkFieldForNextValue = useEventCallback((fieldName, nextValue, callback) => {
115
+ if (resolver) {
116
+ const result = resolver(nextValue);
117
+ if (result instanceof Promise) {
118
+ if (process.env.NODE_ENV !== 'production') {
119
+ console.warn('[rsuite] The `resolver` provided to <Form> returns a Promise. ' + 'Use `checkAsync()` or `checkForFieldAsync()` for async validation.');
120
+ }
121
+ return false;
122
+ }
123
+ const {
124
+ errors
125
+ } = result;
126
+ const fieldError = errors[fieldName];
127
+ const hasFieldError = isValidError(fieldError);
128
+ // Merge resolver errors with existing errors, clearing fields that now pass
129
+ const nextFormError = mergeResolverErrors(realFormError, errors);
130
+ setFormError(nextFormError);
131
+ onCheck?.(nextFormError);
132
+ const callbackResult = {
133
+ hasError: hasFieldError,
134
+ errorMessage: fieldError
135
+ };
136
+ callback?.(hasFieldError ? callbackResult : {
137
+ hasError: false
138
+ });
139
+ if (Object.keys(nextFormError).length > 0) {
140
+ onError?.(nextFormError);
141
+ }
142
+ return !hasFieldError;
143
+ }
62
144
  const model = getCombinedModel();
63
145
  const resultOfCurrentField = model.checkForField(fieldName, nextValue, checkOptions);
64
146
  let nextFormError = {
@@ -117,6 +199,22 @@ export default function useFormValidate(_formError, props) {
117
199
  * Check form data asynchronously and return a Promise
118
200
  */
119
201
  const checkAsync = useEventCallback(() => {
202
+ if (resolver) {
203
+ return Promise.resolve(resolver(formValue || {})).then(({
204
+ errors
205
+ }) => {
206
+ const hasError = Object.keys(errors).length > 0;
207
+ onCheck?.(errors);
208
+ setFormError(errors);
209
+ if (hasError) {
210
+ onError?.(errors);
211
+ }
212
+ return {
213
+ hasError,
214
+ formError: errors
215
+ };
216
+ });
217
+ }
120
218
  const promises = [];
121
219
  const keys = [];
122
220
  const model = getCombinedModel();
@@ -145,6 +243,24 @@ export default function useFormValidate(_formError, props) {
145
243
  });
146
244
  });
147
245
  const checkFieldAsyncForNextValue = useEventCallback((fieldName, nextValue) => {
246
+ if (resolver) {
247
+ return Promise.resolve(resolver(nextValue)).then(({
248
+ errors
249
+ }) => {
250
+ const fieldError = errors[fieldName];
251
+ const hasFieldError = isValidError(fieldError);
252
+ const nextFormError = mergeResolverErrors(realFormError, errors);
253
+ onCheck?.(nextFormError);
254
+ setFormError(nextFormError);
255
+ if (Object.keys(nextFormError).length > 0) {
256
+ onError?.(nextFormError);
257
+ }
258
+ return {
259
+ hasError: hasFieldError,
260
+ errorMessage: fieldError
261
+ };
262
+ });
263
+ }
148
264
  const model = getCombinedModel();
149
265
  return model.checkForFieldAsync(fieldName, nextValue, checkOptions).then(resultOfCurrentField => {
150
266
  let nextFormError = {
@@ -1,5 +1,6 @@
1
1
  import Form from './Form';
2
2
  export type { FormProps } from './Form';
3
3
  export type { FormInstance } from './hooks/useFormRef';
4
+ export type { Resolver, ResolverResult } from './resolvers';
4
5
  export { Form };
5
6
  export default Form;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The result returned by a validation resolver.
3
+ *
4
+ * @template E - The type of the form error map. Defaults to a record of string keys to any.
5
+ */
6
+ export interface ResolverResult<E = Record<string, any>> {
7
+ errors: E;
8
+ }
9
+ /**
10
+ * A resolver is a function that integrates third-party validation libraries
11
+ * (e.g. Yup, Zod, AJV, Joi, Valibot…) with the rsuite `Form` component.
12
+ *
13
+ * The resolver receives the current form values and an optional context object,
14
+ * and must return (or resolve to) a `ResolverResult` whose `errors` property is
15
+ * a plain object that maps field names to error messages / error objects.
16
+ *
17
+ * An **empty** `errors` object means the form is valid.
18
+ *
19
+ * @template V - The shape of the form values. Defaults to `Record<string, any>`.
20
+ * @template E - The shape of the error map. Defaults to `Record<string, any>`.
21
+ *
22
+ * @example
23
+ * ```tsx
24
+ * // Yup example
25
+ * import * as yup from 'yup';
26
+ * import type { Resolver } from 'rsuite';
27
+ *
28
+ * const schema = yup.object({ name: yup.string().email('Invalid email').required() });
29
+ *
30
+ * const resolver: Resolver = async (formValue) => {
31
+ * try {
32
+ * await schema.validate(formValue, { abortEarly: false });
33
+ * return { errors: {} };
34
+ * } catch (e: any) {
35
+ * const errors: Record<string, string> = {};
36
+ * e.inner.forEach((err: yup.ValidationError) => {
37
+ * if (err.path) errors[err.path] = err.message;
38
+ * });
39
+ * return { errors };
40
+ * }
41
+ * };
42
+ *
43
+ * // Zod example
44
+ * import { z } from 'zod';
45
+ *
46
+ * const schema = z.object({ name: z.string().email('Invalid email') });
47
+ *
48
+ * const resolver: Resolver = (formValue) => {
49
+ * const result = schema.safeParse(formValue);
50
+ * if (result.success) return { errors: {} };
51
+ * const errors: Record<string, string> = {};
52
+ * result.error.issues.forEach(err => {
53
+ * if (err.path.length) errors[err.path[0]] = err.message;
54
+ * });
55
+ * return { errors };
56
+ * };
57
+ * ```
58
+ */
59
+ export type Resolver<V = Record<string, any>, E = Record<string, any>> = (formValue: V, context?: any) => ResolverResult<E> | Promise<ResolverResult<E>>;
@@ -0,0 +1,2 @@
1
+ 'use client';
2
+ export {};
@@ -7,6 +7,11 @@ export interface TimelineProps extends BoxProps {
7
7
  align?: 'left' | 'right' | 'alternate';
8
8
  /** Timeline endless **/
9
9
  endless?: boolean;
10
+ /**
11
+ * Reverse the order of Timeline items
12
+ * @version 6.2.0
13
+ **/
14
+ reverse?: boolean;
10
15
  /**
11
16
  * Whether an item is active (with highlighted dot).
12
17
  *
@@ -30,6 +30,7 @@ const Timeline = forwardRef((props, ref) => {
30
30
  className,
31
31
  align = 'left',
32
32
  endless,
33
+ reverse,
33
34
  isItemActive = ACTIVE_LAST,
34
35
  ...rest
35
36
  } = propsWithDefaults;
@@ -41,17 +42,23 @@ const Timeline = forwardRef((props, ref) => {
41
42
  const withTime = some(React.Children.toArray(children), item => item?.props?.time);
42
43
  const classes = merge(className, withPrefix(`align-${align}`, {
43
44
  endless,
44
- 'with-time': withTime
45
+ 'with-time': withTime,
46
+ reverse
45
47
  }));
48
+ const childrenArray = React.Children.toArray(children);
49
+ const orderedChildren = reverse ? [...childrenArray].reverse() : childrenArray;
46
50
  return /*#__PURE__*/React.createElement(Box, _extends({
47
51
  as: as,
48
52
  ref: ref,
49
53
  className: classes
50
- }, rest), rch.mapCloneElement(children, (_child, index) => ({
51
- last: index + 1 === count,
52
- INTERNAL_active: isItemActive(index, count),
53
- align
54
- })));
54
+ }, rest), orderedChildren.map((child, domIndex) => {
55
+ const logicalIndex = reverse ? count - 1 - domIndex : domIndex;
56
+ return /*#__PURE__*/React.cloneElement(child, {
57
+ last: logicalIndex + 1 === count,
58
+ INTERNAL_active: isItemActive(logicalIndex, count),
59
+ align
60
+ });
61
+ }));
55
62
  }, SubcomponentsAndStaticMethods);
56
63
  Timeline.displayName = 'Timeline';
57
64
  export default Timeline;
@@ -90,23 +90,20 @@ function useFlattenTree(data, options) {
90
90
  forceUpdate();
91
91
  }, [callback, forceUpdate, valueKey, labelKey, uncheckableItemValues, childrenKey]);
92
92
  useEffect(() => {
93
- // when data is changed, should clear the flattenedNodes, avoid duplicate keys
94
93
  flattenedNodes.current = {};
95
94
  seenValues.current.clear();
96
95
  flattenTreeData(data);
96
+ if (multiple) {
97
+ updateTreeNodeCheckState(value);
98
+ forceUpdate();
99
+ }
97
100
  }, [data]);
98
101
  useEffect(() => {
99
102
  if (multiple) {
100
103
  updateTreeNodeCheckState(value);
101
104
  forceUpdate();
102
105
  }
103
-
104
- /**
105
- * Add a dependency on data, because when loading data asynchronously through getChildren,
106
- * data may change and the node status needs to be updated.
107
- * @see https://github.com/rsuite/rsuite/issues/3973
108
- */
109
- }, [value, data]);
106
+ }, [value]);
110
107
  return flattenedNodes.current;
111
108
  }
112
109
  export default useFlattenTree;
@@ -99,6 +99,8 @@ export interface UploaderProps extends BaseBoxProps, Omit<UploadTriggerProps, 'o
99
99
  onProgress?: (percent: number, file: FileType, event: ProgressEvent, xhr: XMLHttpRequest) => void;
100
100
  /** In the file list, click the callback function to delete a file */
101
101
  onRemove?: (file: FileType) => void;
102
+ /** Callback function called when all files in the current upload batch have finished (succeeded or failed) */
103
+ onCompletion?: (completedFiles: FileType[], failedFiles: FileType[]) => void;
102
104
  /** Custom render file information */
103
105
  renderFileInfo?: (file: FileType, fileElement: React.ReactNode) => React.ReactNode;
104
106
  /** Custom render thumbnail */
@@ -127,6 +127,7 @@ const Uploader = forwardRef((props, ref) => {
127
127
  onError,
128
128
  onProgress,
129
129
  onReupload,
130
+ onCompletion,
130
131
  ...rest
131
132
  } = propsWithDefaults;
132
133
  const {
@@ -138,6 +139,11 @@ const Uploader = forwardRef((props, ref) => {
138
139
  const rootRef = useRef(null);
139
140
  const xhrs = useRef({});
140
141
  const trigger = useRef(null);
142
+ const uploadingCount = useRef(0);
143
+ const uploadResults = useRef({
144
+ completed: [],
145
+ failed: []
146
+ });
141
147
  const [fileList, dispatch] = useFileList(fileListProp || defaultFileList);
142
148
  useEffect(() => {
143
149
  if (typeof fileListProp !== 'undefined') {
@@ -177,7 +183,16 @@ const Uploader = forwardRef((props, ref) => {
177
183
  };
178
184
  updateFileStatus(nextFile);
179
185
  onSuccess?.(response, nextFile, event, xhr);
180
- }, [onSuccess, updateFileStatus]);
186
+ uploadingCount.current--;
187
+ uploadResults.current.completed.push(nextFile);
188
+ if (uploadingCount.current === 0) {
189
+ onCompletion?.(uploadResults.current.completed, uploadResults.current.failed);
190
+ uploadResults.current = {
191
+ completed: [],
192
+ failed: []
193
+ };
194
+ }
195
+ }, [onCompletion, onSuccess, updateFileStatus]);
181
196
 
182
197
  /**
183
198
  * Callback for file upload error.
@@ -193,7 +208,16 @@ const Uploader = forwardRef((props, ref) => {
193
208
  };
194
209
  updateFileStatus(nextFile);
195
210
  onError?.(status, nextFile, event, xhr);
196
- }, [onError, updateFileStatus]);
211
+ uploadingCount.current--;
212
+ uploadResults.current.failed.push(nextFile);
213
+ if (uploadingCount.current === 0) {
214
+ onCompletion?.(uploadResults.current.completed, uploadResults.current.failed);
215
+ uploadResults.current = {
216
+ completed: [],
217
+ failed: []
218
+ };
219
+ }
220
+ }, [onCompletion, onError, updateFileStatus]);
197
221
 
198
222
  /**
199
223
  * Callback for file upload progress update.
@@ -247,9 +271,19 @@ const Uploader = forwardRef((props, ref) => {
247
271
  fileList.current.forEach(file => {
248
272
  const checkState = shouldUpload?.(file);
249
273
  if (checkState instanceof Promise) {
274
+ uploadingCount.current++;
250
275
  checkState.then(res => {
251
276
  if (res) {
252
277
  handleUploadFile(file);
278
+ } else {
279
+ uploadingCount.current--;
280
+ if (uploadingCount.current === 0) {
281
+ onCompletion?.(uploadResults.current.completed, uploadResults.current.failed);
282
+ uploadResults.current = {
283
+ completed: [],
284
+ failed: []
285
+ };
286
+ }
253
287
  }
254
288
  });
255
289
  return;
@@ -257,6 +291,7 @@ const Uploader = forwardRef((props, ref) => {
257
291
  return;
258
292
  }
259
293
  if (file.status === 'inited') {
294
+ uploadingCount.current++;
260
295
  handleUploadFile(file);
261
296
  }
262
297
  });
@@ -303,6 +338,15 @@ const Uploader = forwardRef((props, ref) => {
303
338
  const nextFileList = fileList.current.filter(f => f.fileKey !== fileKey);
304
339
  if (xhrs.current?.[file.fileKey]?.readyState !== 4) {
305
340
  xhrs.current[file.fileKey]?.abort();
341
+ uploadingCount.current--;
342
+ uploadResults.current.failed.push(file);
343
+ if (uploadingCount.current === 0) {
344
+ onCompletion?.(uploadResults.current.completed, uploadResults.current.failed);
345
+ uploadResults.current = {
346
+ completed: [],
347
+ failed: []
348
+ };
349
+ }
306
350
  }
307
351
  dispatch({
308
352
  type: 'remove',
@@ -313,6 +357,7 @@ const Uploader = forwardRef((props, ref) => {
313
357
  cleanInputValue();
314
358
  });
315
359
  const handleReupload = useEventCallback(file => {
360
+ uploadingCount.current++;
316
361
  autoUpload && handleUploadFile(file);
317
362
  onReupload?.(file);
318
363
  });
@@ -320,6 +365,7 @@ const Uploader = forwardRef((props, ref) => {
320
365
  // public API
321
366
  const start = useCallback(file => {
322
367
  if (file) {
368
+ uploadingCount.current++;
323
369
  handleUploadFile(file);
324
370
  return;
325
371
  }
@@ -48,8 +48,11 @@ const PickerIndicator = ({
48
48
  });
49
49
  };
50
50
  const props = Component === InputGroup.Addon ? {
51
+ className: prefix('toggle-indicator'),
51
52
  disabled
52
- } : undefined;
53
+ } : Component === React.Fragment ? undefined : {
54
+ className: prefix('toggle-indicator')
55
+ };
53
56
  return /*#__PURE__*/React.createElement(Component, props, addon());
54
57
  };
55
58
  export default PickerIndicator;
@@ -1,8 +1,16 @@
1
1
  'use client';
2
2
  import ToastContainer, { defaultToasterContainer } from "./ToastContainer.js";
3
3
  import { RSUITE_TOASTER_ID } from "../internals/symbols.js";
4
+ import { guid } from "../internals/utils/index.js";
4
5
  const containers = new Map();
5
6
 
7
+ /**
8
+ * Track in-progress container creation promises keyed by `${containerId}_${placement}`.
9
+ * This prevents duplicate containers from being created when `push` is called multiple
10
+ * times synchronously (e.g. inside a loop) before the first container has mounted.
11
+ */
12
+ const pendingContainerPromises = new Map();
13
+
6
14
  /**
7
15
  * Create a container instance.
8
16
  * @param placement
@@ -10,7 +18,9 @@ const containers = new Map();
10
18
  */
11
19
  async function createContainer(placement, props) {
12
20
  const [container, containerId] = await ToastContainer.getInstance(props);
13
- containers.set(`${containerId}_${placement}`, container);
21
+ const key = `${containerId}_${placement}`;
22
+ containers.set(key, container);
23
+ pendingContainerPromises.delete(key);
14
24
  return container;
15
25
  }
16
26
 
@@ -30,12 +40,37 @@ toaster.push = (message, options = {}) => {
30
40
  ...restOptions
31
41
  } = options;
32
42
  const containerElement = typeof container === 'function' ? container() : container;
33
- const containerElementId = containerElement ? containerElement[RSUITE_TOASTER_ID] : null;
34
- if (containerElementId) {
43
+ if (containerElement) {
44
+ // Pre-assign the container ID so subsequent synchronous calls can find it
45
+ // before the async container creation has completed.
46
+ if (!containerElement[RSUITE_TOASTER_ID]) {
47
+ containerElement[RSUITE_TOASTER_ID] = guid();
48
+ }
49
+ const containerElementId = containerElement[RSUITE_TOASTER_ID];
50
+ const key = `${containerElementId}_${placement}`;
35
51
  const existedContainer = getContainer(containerElementId, placement);
36
52
  if (existedContainer) {
37
53
  return existedContainer.current?.push(message, restOptions);
38
54
  }
55
+
56
+ // A container creation for this placement may already be in progress (e.g. when `push`
57
+ // is called multiple times synchronously in a loop). Reuse that promise instead of
58
+ // creating a second container.
59
+ const pendingPromise = pendingContainerPromises.get(key);
60
+ if (pendingPromise) {
61
+ return pendingPromise.then(ref => ref.current?.push(message, restOptions));
62
+ }
63
+ const newOptions = {
64
+ ...options,
65
+ container: containerElement,
66
+ placement
67
+ };
68
+ const containerPromise = createContainer(placement, newOptions);
69
+
70
+ // Register the pending promise before any async work begins so that subsequent
71
+ // synchronous `push` calls for the same placement chain onto it.
72
+ pendingContainerPromises.set(key, containerPromise);
73
+ return containerPromise.then(ref => ref.current?.push(message, restOptions));
39
74
  }
40
75
  const newOptions = {
41
76
  ...options,
@@ -367,6 +367,9 @@
367
367
 
368
368
  // Picker clear button
369
369
  .rs-picker-clean {
370
+ display: inline-flex;
371
+ align-items: center;
372
+ justify-content: center;
370
373
  color: var(--rs-text-secondary);
371
374
  transition: 0.2s color linear;
372
375
  cursor: pointer;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rsuite",
3
- "version": "6.1.3",
3
+ "version": "6.2.1",
4
4
  "description": "A suite of react components",
5
5
  "main": "cjs/index.js",
6
6
  "module": "esm/index.js",