playbook_ui 18.2.0 → 18.3.0.pre.rc.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.
- checksums.yaml +4 -4
- data/app/pb_kits/playbook/pb_dropdown/_dropdown.tsx +110 -15
- data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_form.jsx +50 -0
- data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_form_react.md +9 -0
- data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_option_creation.html.erb +16 -0
- data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_option_creation.jsx +27 -0
- data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_option_creation_rails.md +9 -0
- data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_option_creation_react.md +9 -0
- data/app/pb_kits/playbook/pb_dropdown/docs/_playground.json +17 -1
- data/app/pb_kits/playbook/pb_dropdown/docs/_playground.overrides.json +6 -1
- data/app/pb_kits/playbook/pb_dropdown/docs/example.yml +3 -0
- data/app/pb_kits/playbook/pb_dropdown/docs/index.js +3 -1
- data/app/pb_kits/playbook/pb_dropdown/dropdown.html.erb +2 -2
- data/app/pb_kits/playbook/pb_dropdown/dropdown.rb +14 -0
- data/app/pb_kits/playbook/pb_dropdown/dropdown.test.jsx +39 -0
- data/app/pb_kits/playbook/pb_dropdown/dropdown_creation.test.jsx +414 -0
- data/app/pb_kits/playbook/pb_dropdown/dropdown_form.test.jsx +173 -0
- data/app/pb_kits/playbook/pb_dropdown/dropdown_index.test.js +291 -0
- data/app/pb_kits/playbook/pb_dropdown/hooks/useHandleOnKeydown.tsx +22 -5
- data/app/pb_kits/playbook/pb_dropdown/index.js +94 -30
- data/app/pb_kits/playbook/pb_dropdown/keyboard_accessibility.js +3 -1
- data/app/pb_kits/playbook/pb_dropdown/kit.schema.json +18 -2
- data/app/pb_kits/playbook/pb_dropdown/subcomponents/DropdownContainer.tsx +43 -25
- data/app/pb_kits/playbook/pb_dropdown/subcomponents/DropdownFormFields.tsx +83 -0
- data/app/pb_kits/playbook/pb_dropdown/subcomponents/DropdownOption.tsx +13 -5
- data/app/pb_kits/playbook/pb_dropdown/utilities/optionCreation.ts +21 -0
- data/app/pb_kits/playbook/pb_dropdown/utilities/quickpickDateSync.test.ts +24 -0
- data/app/pb_kits/playbook/pb_filter/Filter/SortMenu.tsx +8 -1
- data/app/pb_kits/playbook/pb_filter/docs/_filter_one_click.html.erb +38 -0
- data/app/pb_kits/playbook/pb_filter/docs/_filter_one_click.jsx +69 -0
- data/app/pb_kits/playbook/pb_filter/docs/_filter_one_click.md +1 -0
- data/app/pb_kits/playbook/pb_filter/docs/example.yml +2 -0
- data/app/pb_kits/playbook/pb_filter/docs/index.js +2 -1
- data/app/pb_kits/playbook/pb_filter/filter.html.erb +4 -4
- data/app/pb_kits/playbook/pb_filter/filter.rb +28 -0
- data/app/pb_kits/playbook/pb_filter/filter.test.js +41 -0
- data/app/pb_kits/playbook/pb_form/docs/_description_react.md +3 -0
- data/app/pb_kits/playbook/pb_form/docs/_form_default_values.jsx +898 -0
- data/app/pb_kits/playbook/pb_form/docs/_form_default_values_react.md +3 -0
- data/app/pb_kits/playbook/pb_form/docs/_form_defaults.jsx +879 -0
- data/app/pb_kits/playbook/pb_form/docs/_form_defaults_react.md +29 -0
- data/app/pb_kits/playbook/pb_form/docs/_form_loading.jsx +75 -0
- data/app/pb_kits/playbook/pb_form/docs/{_form_loading.md → _form_loading_rails.md} +1 -0
- data/app/pb_kits/playbook/pb_form/docs/_form_loading_react.md +1 -0
- data/app/pb_kits/playbook/pb_form/docs/_form_model_values.jsx +147 -0
- data/app/pb_kits/playbook/pb_form/docs/_form_model_values_react.md +16 -0
- data/app/pb_kits/playbook/pb_form/docs/_form_multi_column.jsx +164 -0
- data/app/pb_kits/playbook/pb_form/docs/_form_multi_column_react.md +3 -0
- data/app/pb_kits/playbook/pb_form/docs/_form_validation.jsx +1057 -0
- data/app/pb_kits/playbook/pb_form/docs/_form_validation_react.md +26 -0
- data/app/pb_kits/playbook/pb_form/docs/example.yml +7 -0
- data/app/pb_kits/playbook/pb_full_screen/_full_screen.scss +6 -0
- data/app/pb_kits/playbook/pb_full_screen/_full_screen.tsx +11 -2
- data/app/pb_kits/playbook/pb_full_screen/docs/_full_screen_advanced_table_sticky_header.jsx +73 -0
- data/app/pb_kits/playbook/pb_full_screen/docs/_full_screen_advanced_table_sticky_header.md +1 -0
- data/app/pb_kits/playbook/pb_full_screen/docs/example.yml +1 -0
- data/app/pb_kits/playbook/pb_full_screen/docs/index.js +1 -0
- data/app/pb_kits/playbook/pb_full_screen/full_screen.test.jsx +4 -2
- data/app/pb_kits/playbook/pb_label_pill/_label_pill.tsx +3 -1
- data/app/pb_kits/playbook/pb_label_pill/kit.schema.json +1 -0
- data/app/pb_kits/playbook/pb_legend/_legend.tsx +3 -1
- data/app/pb_kits/playbook/pb_legend/kit.schema.json +1 -0
- data/app/pb_kits/playbook/pb_online_status/_online_status.tsx +3 -1
- data/app/pb_kits/playbook/pb_online_status/kit.schema.json +1 -0
- data/app/pb_kits/playbook/pb_pill/_pill.tsx +3 -1
- data/app/pb_kits/playbook/pb_pill/kit.schema.json +1 -0
- data/app/pb_kits/playbook/pb_progress_step/_progress_step_item.tsx +3 -1
- data/app/pb_kits/playbook/pb_rich_text_editor/docs/_rich_text_editor_advanced_required_indicator.md +2 -2
- data/app/pb_kits/playbook/pb_rich_text_editor/docs/_rich_text_editor_rails_required_indicator.html.erb +9 -0
- data/app/pb_kits/playbook/pb_rich_text_editor/docs/_rich_text_editor_rails_required_indicator.md +3 -0
- data/app/pb_kits/playbook/pb_rich_text_editor/docs/example.yml +1 -0
- data/app/pb_kits/playbook/pb_rich_text_editor/rich_text_editor.html.erb +3 -1
- data/app/pb_kits/playbook/pb_source/_source.tsx +3 -1
- data/app/pb_kits/playbook/pb_source/kit.schema.json +1 -0
- data/app/pb_kits/playbook/pb_stat_change/_stat_change.tsx +3 -1
- data/app/pb_kits/playbook/pb_stat_change/kit.schema.json +1 -0
- data/app/pb_kits/playbook/pb_timeline/_item.tsx +3 -1
- data/app/pb_kits/playbook/pb_typeahead/_typeahead.scss +73 -0
- data/app/pb_kits/playbook/pb_typeahead/_typeahead.test.jsx +158 -12
- data/app/pb_kits/playbook/pb_typeahead/_typeahead.tsx +3 -3
- data/app/pb_kits/playbook/pb_typeahead/components/PillRenderer.tsx +15 -11
- data/app/pb_kits/playbook/pb_typeahead/components/ValueContainer.tsx +29 -30
- data/app/pb_kits/playbook/pb_typeahead/components/usePillDropIndicator.ts +155 -0
- data/app/pb_kits/playbook/pb_typeahead/docs/_playground.json +5 -5
- data/app/pb_kits/playbook/pb_typeahead/docs/_playground.overrides.json +2 -2
- data/app/pb_kits/playbook/pb_typeahead/docs/_typeahead_draggable_pills.html.erb +0 -1
- data/app/pb_kits/playbook/pb_typeahead/docs/_typeahead_draggable_pills.jsx +0 -1
- data/app/pb_kits/playbook/pb_typeahead/docs/_typeahead_draggable_pills_rails.md +3 -3
- data/app/pb_kits/playbook/pb_typeahead/docs/_typeahead_draggable_pills_react.md +7 -0
- data/app/pb_kits/playbook/pb_typeahead/kit.schema.json +3 -3
- data/app/pb_kits/playbook/pb_typeahead/typeahead.rb +3 -3
- data/app/pb_kits/playbook/utilities/test/globalProps/onClick.test.js +116 -0
- data/dist/ai/all-schemas.json +53 -14
- data/dist/ai/forms.json +2 -2
- data/dist/ai/index.json +2 -2
- data/dist/ai/kits/dropdown.schema.json +44 -11
- data/dist/ai/kits/label_pill.schema.json +1 -0
- data/dist/ai/kits/legend.schema.json +1 -0
- data/dist/ai/kits/online_status.schema.json +1 -0
- data/dist/ai/kits/pill.schema.json +1 -0
- data/dist/ai/kits/source.schema.json +1 -0
- data/dist/ai/kits/stat_change.schema.json +1 -0
- data/dist/ai/kits/typeahead.schema.json +3 -3
- data/dist/ai/playgrounds/dropdown.json +1 -1
- data/dist/ai/playgrounds/index.json +1 -1
- data/dist/ai/playgrounds/typeahead.json +1 -1
- data/dist/chunks/_pagination-D0hcdUl3.js +1 -0
- data/dist/chunks/{_pb_line_graph-Du7xE9vl.js → _pb_line_graph-BKwbOeqo.js} +1 -1
- data/dist/chunks/{_detail-Bsvl694i.js → _tooltip-DfZEKsnv.js} +2 -2
- data/dist/chunks/_typeahead-CmJ8Ggzz.js +1 -0
- data/dist/chunks/{globalProps-B120NbSJ.js → globalProps-BivjwRew.js} +1 -1
- data/dist/chunks/lib-C50eCpoN.js +29 -0
- data/dist/chunks/{time_picker_helper-CYGg0UQ0.js → time_picker_helper-DfKvHCJu.js} +1 -1
- data/dist/chunks/vendor.js +2 -2
- data/dist/playbook-rails-react-bindings.js +1 -1
- data/dist/playbook-rails.js +1 -1
- data/dist/playbook.css +1 -1
- data/lib/playbook/version.rb +2 -2
- metadata +53 -18
- data/app/pb_kits/playbook/pb_typeahead/docs/_typeahead_draggable_pills.md +0 -7
- data/dist/chunks/_table-Bf6LGyL9.js +0 -1
- data/dist/chunks/_typeahead-w6S5Vx4b.js +0 -1
- data/dist/chunks/lib-Buo6zHEw.js +0 -29
- /data/app/pb_kits/playbook/pb_form/docs/{_description.md → _description_rails.md} +0 -0
- /data/app/pb_kits/playbook/pb_form/docs/{_form_default_values.md → _form_default_values_rails.md} +0 -0
- /data/app/pb_kits/playbook/pb_form/docs/{_form_defaults.md → _form_defaults_rails.md} +0 -0
- /data/app/pb_kits/playbook/pb_form/docs/{_form_model_values.md → _form_model_values_rails.md} +0 -0
- /data/app/pb_kits/playbook/pb_form/docs/{_form_multi_column.md → _form_multi_column_rails.md} +0 -0
- /data/app/pb_kits/playbook/pb_form/docs/{_form_validation.md → _form_validation_rails.md} +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dce454b27ec0be36f4812b6955579e934aa2d63d856a62d796e7ef64b05b0a94
|
|
4
|
+
data.tar.gz: 56f6845557ded84094f8757e2a29f3151a03407430f478ad6570aaf8dca25e93
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 259f6127136c2e6f3d26b5eb90e32c641c357792c0ff0dfefe5ba8e98dc808f41a55451456c39c6fa228dae125d8aa3d17296090b65d5d1be1065cd0ef7fd55c
|
|
7
|
+
data.tar.gz: 6476556760b7bf8c5f11852974131592ffe329f86beb23fcdbb81103699f94af9d982ad47daa8537a3334d46601ca522ff34c25403406061cebd82f565e4a4bf
|
|
@@ -12,8 +12,10 @@ import DropdownContainer from "./subcomponents/DropdownContainer";
|
|
|
12
12
|
import DropdownContext from "./context";
|
|
13
13
|
import DropdownOption from "./subcomponents/DropdownOption";
|
|
14
14
|
import DropdownTrigger from "./subcomponents/DropdownTrigger";
|
|
15
|
+
import DropdownFormFields from "./subcomponents/DropdownFormFields";
|
|
15
16
|
import useDropdown from "./hooks/useDropdown";
|
|
16
17
|
import useAsyncOptions, { LoadOptions } from "./hooks/useAsyncOptions";
|
|
18
|
+
import { getCreationOption, matchesOptionExactly } from "./utilities/optionCreation";
|
|
17
19
|
import getQuickPickOptions from "./quickpick";
|
|
18
20
|
|
|
19
21
|
import {
|
|
@@ -99,6 +101,7 @@ type CustomQuickPickDates = {
|
|
|
99
101
|
type InputChangeReason = "input" | "clear";
|
|
100
102
|
|
|
101
103
|
type DropdownProps = {
|
|
104
|
+
allowOptionCreation?: boolean;
|
|
102
105
|
aria?: { [key: string]: string };
|
|
103
106
|
async?: boolean;
|
|
104
107
|
loadOptions?: LoadOptions;
|
|
@@ -122,6 +125,7 @@ type DropdownProps = {
|
|
|
122
125
|
data?: { [key: string]: string };
|
|
123
126
|
defaultValue?: GenericObject;
|
|
124
127
|
error?: string;
|
|
128
|
+
enableFormSupport?: boolean;
|
|
125
129
|
htmlOptions?: { [key: string]: string | number | boolean | (() => void) },
|
|
126
130
|
id?: string;
|
|
127
131
|
isClosed?: boolean;
|
|
@@ -143,6 +147,8 @@ type DropdownProps = {
|
|
|
143
147
|
fontColor?: string;
|
|
144
148
|
};
|
|
145
149
|
requiredIndicator?: boolean;
|
|
150
|
+
required?: boolean;
|
|
151
|
+
validationMessage?: string;
|
|
146
152
|
};
|
|
147
153
|
|
|
148
154
|
interface DropdownComponent
|
|
@@ -154,6 +160,7 @@ interface DropdownComponent
|
|
|
154
160
|
|
|
155
161
|
let Dropdown = (props: DropdownProps, ref: any): React.ReactElement | null => {
|
|
156
162
|
const {
|
|
163
|
+
allowOptionCreation = false,
|
|
157
164
|
aria = {},
|
|
158
165
|
async = false,
|
|
159
166
|
loadOptions,
|
|
@@ -163,7 +170,7 @@ let Dropdown = (props: DropdownProps, ref: any): React.ReactElement | null => {
|
|
|
163
170
|
onInputChange,
|
|
164
171
|
renderOption,
|
|
165
172
|
value,
|
|
166
|
-
autocomplete = false,
|
|
173
|
+
autocomplete: autocompleteProp = false,
|
|
167
174
|
blankSelection = '',
|
|
168
175
|
children,
|
|
169
176
|
className,
|
|
@@ -175,7 +182,8 @@ let Dropdown = (props: DropdownProps, ref: any): React.ReactElement | null => {
|
|
|
175
182
|
data = {},
|
|
176
183
|
defaultValue = {},
|
|
177
184
|
disabled = false,
|
|
178
|
-
error,
|
|
185
|
+
error: errorProp,
|
|
186
|
+
enableFormSupport = false,
|
|
179
187
|
htmlOptions = {},
|
|
180
188
|
id,
|
|
181
189
|
isClosed = true,
|
|
@@ -194,9 +202,18 @@ let Dropdown = (props: DropdownProps, ref: any): React.ReactElement | null => {
|
|
|
194
202
|
separators = true,
|
|
195
203
|
variant = "default",
|
|
196
204
|
activeStyle,
|
|
197
|
-
requiredIndicator = false
|
|
205
|
+
requiredIndicator = false,
|
|
206
|
+
required = false,
|
|
207
|
+
validationMessage = "Please fill out this field."
|
|
198
208
|
} = props;
|
|
199
209
|
|
|
210
|
+
const creationEnabled = allowOptionCreation && variant !== "quickpick";
|
|
211
|
+
const autocomplete = autocompleteProp || creationEnabled;
|
|
212
|
+
|
|
213
|
+
const [formInvalid, setFormInvalid] = useState(false);
|
|
214
|
+
const formError = formInvalid ? validationMessage : "";
|
|
215
|
+
const error = enableFormSupport ? errorProp || formError : errorProp;
|
|
216
|
+
|
|
200
217
|
const ariaProps = buildAriaProps(aria);
|
|
201
218
|
const dataProps = buildDataProps(data);
|
|
202
219
|
const htmlProps = buildHtmlProps(htmlOptions);
|
|
@@ -365,6 +382,11 @@ let Dropdown = (props: DropdownProps, ref: any): React.ReactElement | null => {
|
|
|
365
382
|
? [selected]
|
|
366
383
|
: [];
|
|
367
384
|
|
|
385
|
+
const hasFormSelection = enableFormSupport && selectedArray.some((option) => String(option.id ?? option.value ?? "") !== "");
|
|
386
|
+
useEffect(() => {
|
|
387
|
+
if (!enableFormSupport || hasFormSelection || !required) setFormInvalid(false);
|
|
388
|
+
}, [enableFormSupport, hasFormSelection, required]);
|
|
389
|
+
|
|
368
390
|
const selectedOptionIds = useMemo(() => (
|
|
369
391
|
multiSelect && getOptionValue
|
|
370
392
|
? new Set(selectedArray.map((option) => String(getOptionValue(option))))
|
|
@@ -415,7 +437,16 @@ let Dropdown = (props: DropdownProps, ref: any): React.ReactElement | null => {
|
|
|
415
437
|
return optionsWithBlankSelection.filter((option: GenericObject) => !selectedArray.some((sel) => isSameOption(sel, option)));
|
|
416
438
|
}, [optionsWithBlankSelection, selectedArray, multiSelect, isSameOption, selectedOptionIds, getOptionValue]);
|
|
417
439
|
|
|
418
|
-
const
|
|
440
|
+
const searchIsPending = loading || (asyncEnabled && asyncStatus === "loading");
|
|
441
|
+
const searchFailed = asyncEnabled && asyncStatus === "error";
|
|
442
|
+
const canCreateOption = creationEnabled && !disabled && !searchIsPending && !searchFailed;
|
|
443
|
+
let creationOption: GenericObject | null = null;
|
|
444
|
+
if (canCreateOption) {
|
|
445
|
+
const existingOptions = Array.isArray(dropdownOptions) ? dropdownOptions : [];
|
|
446
|
+
creationOption = getCreationOption(filterItem, [...existingOptions, ...selectedArray]);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const matchingOptions = useMemo(() => {
|
|
419
450
|
const selectedLabel =
|
|
420
451
|
!multiSelect &&
|
|
421
452
|
!Array.isArray(selected) &&
|
|
@@ -431,9 +462,29 @@ let Dropdown = (props: DropdownProps, ref: any): React.ReactElement | null => {
|
|
|
431
462
|
const filterText =
|
|
432
463
|
selectedLabel && filterItem === selectedLabel ? "" : filterItem;
|
|
433
464
|
return availableOptions.filter((opt: GenericObject) =>
|
|
434
|
-
String(opt.label).toLowerCase().includes(filterText.toLowerCase())
|
|
465
|
+
String(opt.label).toLowerCase().includes(filterText.toLowerCase()) ||
|
|
466
|
+
(creationEnabled && matchesOptionExactly(opt, filterText))
|
|
435
467
|
);
|
|
436
|
-
}, [availableOptions, filterItem, multiSelect, selected, asyncEnabled, autocomplete]);
|
|
468
|
+
}, [availableOptions, filterItem, multiSelect, selected, asyncEnabled, autocomplete, creationEnabled]);
|
|
469
|
+
|
|
470
|
+
const filteredOptions = creationOption ? [...matchingOptions, creationOption] : matchingOptions;
|
|
471
|
+
const inputShowsSelection = !multiSelect && !Array.isArray(selected) && filterItem === selected?.label;
|
|
472
|
+
let creationFocusIndex = -1;
|
|
473
|
+
if (creationEnabled && creationOption) {
|
|
474
|
+
creationFocusIndex = matchingOptions.length;
|
|
475
|
+
} else if (creationEnabled) {
|
|
476
|
+
creationFocusIndex = matchingOptions.findIndex((option: GenericObject) => !option.disabled);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
useEffect(() => {
|
|
480
|
+
if (!creationEnabled) return;
|
|
481
|
+
if (isDropDownClosed || !filterItem.trim() || inputShowsSelection) {
|
|
482
|
+
setFocusedOptionIndex(-1);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
setFocusedOptionIndex(creationFocusIndex);
|
|
487
|
+
}, [creationEnabled, isDropDownClosed, filterItem, inputShowsSelection, creationFocusIndex]);
|
|
437
488
|
|
|
438
489
|
// Empty copy follows the settled search. Hiding already-selected rows must not turn a
|
|
439
490
|
// successful result list into "No results found", including the last pick while the menu stays open.
|
|
@@ -441,18 +492,25 @@ let Dropdown = (props: DropdownProps, ref: any): React.ReactElement | null => {
|
|
|
441
492
|
? noOptionsText || "No results found"
|
|
442
493
|
: "";
|
|
443
494
|
|
|
495
|
+
let asyncStatusText = "";
|
|
496
|
+
if (asyncEnabled) {
|
|
497
|
+
if (searchIsPending) asyncStatusText = "Loading…";
|
|
498
|
+
else if (searchFailed) asyncStatusText = "Unable to load options";
|
|
499
|
+
else if (!creationOption) asyncStatusText = asyncEmptyCopy;
|
|
500
|
+
}
|
|
501
|
+
|
|
444
502
|
// A keystroke clears the highlight. Focus the first result once the next list arrives,
|
|
445
503
|
// including options the application loads itself.
|
|
446
504
|
const asyncResultOptions = loadOptions ? loadedOptions : options;
|
|
447
505
|
useEffect(() => {
|
|
448
|
-
if (asyncEnabled && !isDropDownClosed) {
|
|
506
|
+
if (!creationEnabled && asyncEnabled && !isDropDownClosed) {
|
|
449
507
|
setFocusedOptionIndex(filteredOptions.length ? 0 : -1);
|
|
450
508
|
}
|
|
451
|
-
}, [asyncResultOptions]);
|
|
509
|
+
}, [asyncResultOptions, creationEnabled]);
|
|
452
510
|
|
|
453
511
|
// For keyboard accessibility: Set focus within dropdown to selected item if it exists
|
|
454
512
|
useEffect(() => {
|
|
455
|
-
if (!isDropDownClosed) {
|
|
513
|
+
if (!creationEnabled && !isDropDownClosed) {
|
|
456
514
|
let newIndex = 0;
|
|
457
515
|
if (selected && !Array.isArray(selected) && selected.label) {
|
|
458
516
|
const selectedIndex = filteredOptions.findIndex((option: GenericObject) => isSameOption(option, selected));
|
|
@@ -462,7 +520,7 @@ let Dropdown = (props: DropdownProps, ref: any): React.ReactElement | null => {
|
|
|
462
520
|
}
|
|
463
521
|
setFocusedOptionIndex(newIndex);
|
|
464
522
|
}
|
|
465
|
-
}, [isDropDownClosed]);
|
|
523
|
+
}, [isDropDownClosed, creationEnabled]);
|
|
466
524
|
|
|
467
525
|
// Auto-position dropdown above/below based on available space or fixed/absolute when portaled to body / floating root
|
|
468
526
|
useLayoutEffect(() => {
|
|
@@ -580,10 +638,12 @@ let Dropdown = (props: DropdownProps, ref: any): React.ReactElement | null => {
|
|
|
580
638
|
}
|
|
581
639
|
} else {
|
|
582
640
|
setSelected(clickedItem);
|
|
583
|
-
//
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
641
|
+
// Plain uncontrolled autocomplete clears its query after selection.
|
|
642
|
+
const keepSelectedLabel = autocomplete && (creationEnabled || asyncEnabled || isControlled);
|
|
643
|
+
const nextQuery = keepSelectedLabel
|
|
644
|
+
? (isControlled ? (selected as GenericObject)?.label : clickedItem.label) || ""
|
|
645
|
+
: "";
|
|
646
|
+
setFilterItem(nextQuery);
|
|
587
647
|
if (shouldCloseOnClick) {
|
|
588
648
|
setIsDropDownClosed(true);
|
|
589
649
|
}
|
|
@@ -724,6 +784,27 @@ let Dropdown = (props: DropdownProps, ref: any): React.ReactElement | null => {
|
|
|
724
784
|
}
|
|
725
785
|
}, [variant, initialSelected, controlsStartId, controlsEndId]);
|
|
726
786
|
|
|
787
|
+
const handleFormInvalid = () => {
|
|
788
|
+
setFormInvalid(true);
|
|
789
|
+
const focusTarget = inputRef.current || inputWrapperRef.current;
|
|
790
|
+
focusTarget?.focus();
|
|
791
|
+
};
|
|
792
|
+
|
|
793
|
+
const handleFormReset = () => {
|
|
794
|
+
const hasDefaultSelection = Object.keys(initialSelected).length > 0;
|
|
795
|
+
const resetValue = multiSelect || hasDefaultSelection ? initialSelected : null;
|
|
796
|
+
const resetQuery = autocomplete && !multiSelect
|
|
797
|
+
? (initialSelected as GenericObject).label || ""
|
|
798
|
+
: "";
|
|
799
|
+
|
|
800
|
+
setSelected(initialSelected);
|
|
801
|
+
handleSelectionChange(resetValue);
|
|
802
|
+
setFilterItem(resetQuery);
|
|
803
|
+
setFormInvalid(false);
|
|
804
|
+
setIsDropDownClosed(true);
|
|
805
|
+
if (asyncEnabled) clearAsync();
|
|
806
|
+
};
|
|
807
|
+
|
|
727
808
|
return (
|
|
728
809
|
<div {...ariaProps}
|
|
729
810
|
{...dataProps}
|
|
@@ -738,7 +819,9 @@ let Dropdown = (props: DropdownProps, ref: any): React.ReactElement | null => {
|
|
|
738
819
|
<DropdownContext.Provider
|
|
739
820
|
value={{
|
|
740
821
|
asyncEnabled,
|
|
741
|
-
asyncStatus:
|
|
822
|
+
asyncStatus: asyncStatusText,
|
|
823
|
+
creationEnabled,
|
|
824
|
+
creationOption,
|
|
742
825
|
renderOption,
|
|
743
826
|
optionKey,
|
|
744
827
|
isControlled,
|
|
@@ -785,6 +868,18 @@ let Dropdown = (props: DropdownProps, ref: any): React.ReactElement | null => {
|
|
|
785
868
|
toggleDropdown
|
|
786
869
|
}}
|
|
787
870
|
>
|
|
871
|
+
{enableFormSupport && (
|
|
872
|
+
<DropdownFormFields
|
|
873
|
+
disabled={disabled}
|
|
874
|
+
multiSelect={multiSelect}
|
|
875
|
+
name={name}
|
|
876
|
+
onInvalid={handleFormInvalid}
|
|
877
|
+
onReset={handleFormReset}
|
|
878
|
+
required={required}
|
|
879
|
+
selected={selectedArray}
|
|
880
|
+
validationMessage={validationMessage}
|
|
881
|
+
/>
|
|
882
|
+
)}
|
|
788
883
|
{label && (
|
|
789
884
|
<label
|
|
790
885
|
data-dropdown="pb-dropdown-label"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import React, { useState } from 'react'
|
|
2
|
+
import Dropdown from '../_dropdown'
|
|
3
|
+
import Button from '../../pb_button/_button'
|
|
4
|
+
import Body from '../../pb_body/_body'
|
|
5
|
+
|
|
6
|
+
const DropdownForm = () => {
|
|
7
|
+
const [submittedTags, setSubmittedTags] = useState(null)
|
|
8
|
+
const options = [
|
|
9
|
+
{ id: 'product', label: 'Product', value: 'product' },
|
|
10
|
+
{ id: 'support', label: 'Support', value: 'support' },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
const handleSubmit = (event) => {
|
|
14
|
+
event.preventDefault()
|
|
15
|
+
setSubmittedTags(new FormData(event.currentTarget).getAll('tags[]'))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<form onReset={() => setSubmittedTags(null)}
|
|
20
|
+
onSubmit={handleSubmit}
|
|
21
|
+
>
|
|
22
|
+
<Dropdown
|
|
23
|
+
enableFormSupport
|
|
24
|
+
label="Tags"
|
|
25
|
+
marginBottom="md"
|
|
26
|
+
multiSelect
|
|
27
|
+
name="tags[]"
|
|
28
|
+
options={options}
|
|
29
|
+
required
|
|
30
|
+
requiredIndicator
|
|
31
|
+
validationMessage="Select at least one tag."
|
|
32
|
+
/>
|
|
33
|
+
<Button htmlType="submit"
|
|
34
|
+
text="Submit"
|
|
35
|
+
/>
|
|
36
|
+
<Button htmlType="reset"
|
|
37
|
+
marginLeft="xs"
|
|
38
|
+
text="Reset"
|
|
39
|
+
variant="secondary"
|
|
40
|
+
/>
|
|
41
|
+
{submittedTags && (
|
|
42
|
+
<Body marginTop="sm"
|
|
43
|
+
text={`Submitted tags: ${submittedTags.join(', ')}`}
|
|
44
|
+
/>
|
|
45
|
+
)}
|
|
46
|
+
</form>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export default DropdownForm
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Set `enableFormSupport` to opt into React native form submission, required validation, and reset handling. The prop defaults to `false`. Passing `name` or `required` alone does not enable native form behavior. Option creation is a separate feature controlled by `allowOptionCreation`.
|
|
2
|
+
|
|
3
|
+
With `enableFormSupport` enabled and `name` provided, Dropdown renders named hidden inputs. Each selected option submits its `id`, falling back to `value`. For `multiSelect`, each selection gets a separate input and the name ends in `[]`; an existing `[]` suffix is preserved. Disabled Dropdowns do not submit values. An empty selection submits an empty string.
|
|
4
|
+
|
|
5
|
+
The example below requires at least one tag. Submit an empty selection to see validation, select tags and submit to see the values from `FormData`, or use Reset to restore the default selection.
|
|
6
|
+
|
|
7
|
+
Use `required` for native form constraint validation, `validationMessage` for the inline required message, and `requiredIndicator` for the visual asterisk. Invalid submissions focus the trigger or autocomplete input. Typing a query does not satisfy required validation; select an option first. The unnamed validation input never submits query text.
|
|
8
|
+
|
|
9
|
+
Native form reset restores `defaultValue`, clears the query and validation message, and closes the menu. A controlled Dropdown reports that reset through its normal selection callbacks; the parent must update `value`. Canceling the reset event preserves the selection. In a controlled form library, pass `value`, `name`, and `onChange`; Dropdown sends `{ target: { name, value } }`, with an option object or array as `value`. The forwarded ref exposes `clearSelected`, rather than a native input ref.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<%= pb_rails("dropdown", props: {
|
|
2
|
+
allow_option_creation: true,
|
|
3
|
+
label: "Grid Alignment Group",
|
|
4
|
+
margin_bottom: "md",
|
|
5
|
+
name: "grid_alignment_group",
|
|
6
|
+
options: [{ id: "existing", label: "Existing group", value: "existing" }],
|
|
7
|
+
}) %>
|
|
8
|
+
|
|
9
|
+
<%= pb_rails("dropdown", props: {
|
|
10
|
+
allow_option_creation: true,
|
|
11
|
+
label: "Home IDs",
|
|
12
|
+
multi_select: true,
|
|
13
|
+
name: "home_ids[]",
|
|
14
|
+
options: [{ id: "existing", label: "Existing group", value: "existing" }],
|
|
15
|
+
placeholder: "Type a home ID and press Enter",
|
|
16
|
+
}) %>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import Dropdown from '../_dropdown'
|
|
3
|
+
|
|
4
|
+
const DropdownOptionCreation = () => {
|
|
5
|
+
const options = [{ id: 'existing', label: 'Existing tag', value: 'existing' }]
|
|
6
|
+
|
|
7
|
+
return (
|
|
8
|
+
<>
|
|
9
|
+
<Dropdown
|
|
10
|
+
allowOptionCreation
|
|
11
|
+
label="Grid Alignment Group"
|
|
12
|
+
marginBottom="md"
|
|
13
|
+
name="gridAlignmentGroup"
|
|
14
|
+
options={options}
|
|
15
|
+
/>
|
|
16
|
+
<Dropdown
|
|
17
|
+
allowOptionCreation
|
|
18
|
+
label="Tags"
|
|
19
|
+
multiSelect
|
|
20
|
+
name="tags[]"
|
|
21
|
+
options={options}
|
|
22
|
+
/>
|
|
23
|
+
</>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default DropdownOptionCreation
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Enable `allow_option_creation: true` to select text that is not already an option. This also enables autocomplete on the default trigger. With custom subcomponents, provide an autocomplete trigger or a searchable container. Creation supports single and multiple selections, including async search, and is ignored for the quickpick variant.
|
|
2
|
+
|
|
3
|
+
Type a new value and click the `Create "…"` row, or use the arrow keys and Enter. A non-empty search focuses the Create row when it is available; arrow keys can choose another option. Creation trims surrounding whitespace, rejects blank text, and suppresses case-insensitive matches against existing or selected labels, values, and IDs. An exact ID or value match keeps the existing option visible and selectable even when its label does not contain the query. The selected object is `{ id: text, label: text, value: text, __isNew__: true }`. The Create label is presentation only.
|
|
4
|
+
|
|
5
|
+
Created selections emit the normal `pb:dropdown:selected` event with the option object, or an array for `multi_select: true`. Rails retains its existing native form support. Multi-select fields submit individual selected values in selection order, and the field name receives a `[]` suffix only if it does not already have one. While selections exist, the empty base input is disabled so it does not add a blank array entry. Clearing the selection restores the base input for required validation; disabled Dropdowns stay excluded from submission.
|
|
6
|
+
|
|
7
|
+
For async usage, combine `allow_option_creation: true` with `async: true` and handle the existing `pb:dropdown:search` event using its `setResults` and `setError` callbacks. Creation is hidden during pending searches and errors, then offered when settled results have no exact match. Creation can be offered below `search_term_minimum_length`, when no remote search runs.
|
|
8
|
+
|
|
9
|
+
Creation does not persist a server record or append to application-owned options; persist selections through your normal form submission or selection handler.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Enable `allowOptionCreation` to select text that is not already an option. This also enables autocomplete on the default trigger. Creation supports single and multiple selections, including async search, and is ignored for the quickpick variant.
|
|
2
|
+
|
|
3
|
+
Type a new value and click the `Create "…"` row, or use the arrow keys and Enter. Creation trims surrounding whitespace, rejects blank text, and suppresses case-insensitive matches against existing or selected labels, values, and IDs. An exact ID or value match keeps the existing option visible and selectable even when its label does not contain the query. The selected object is `{ id: text, label: text, value: text, __isNew__: true }`. The Create label is presentation only.
|
|
4
|
+
|
|
5
|
+
Created selections use the normal `onSelect(optionOrArray)` and `onChange({ target: { name, value } })` callbacks. Multiple selections produce an array of option objects. Native form submission additionally requires `enableFormSupport`; option creation alone does not enable it. Creation does not persist a server record or append to application-owned options; persist selections through your normal form submission or selection handler.
|
|
6
|
+
|
|
7
|
+
For async usage, combine `allowOptionCreation` with `async` and your existing `loadOptions`; both Promise and callback loaders are supported. Creation is hidden during pending searches and errors, then offered when settled results have no exact match. Applications that manage fetching themselves must pass `loading` while the request is pending.
|
|
8
|
+
|
|
9
|
+
A non-empty search focuses the Create row when it is available; arrow keys can choose another option. Closing the menu or clearing the query removes automatic option focus. Enter selects a highlighted option, including when the query is blank. Enter with a closed menu, or a blank query and no highlighted option, remains available for normal form submission and does not change the selection.
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
"options": "Options.data"
|
|
6
6
|
},
|
|
7
7
|
"defaults": {
|
|
8
|
+
"allowOptionCreation": false,
|
|
8
9
|
"async": false,
|
|
9
10
|
"autocomplete": false,
|
|
10
11
|
"blankSelection": "",
|
|
@@ -19,6 +20,8 @@
|
|
|
19
20
|
"controlsStartId": "",
|
|
20
21
|
"controlsEndId": "",
|
|
21
22
|
"requiredIndicator": false,
|
|
23
|
+
"required": false,
|
|
24
|
+
"validationMessage": "",
|
|
22
25
|
"dark": false,
|
|
23
26
|
"label": "Select a Country",
|
|
24
27
|
"options": [
|
|
@@ -124,7 +127,11 @@
|
|
|
124
127
|
"closeOnClick",
|
|
125
128
|
"constrainHeight",
|
|
126
129
|
"isClosed",
|
|
127
|
-
"
|
|
130
|
+
"enableFormSupport",
|
|
131
|
+
"requiredIndicator",
|
|
132
|
+
"required",
|
|
133
|
+
"validationMessage",
|
|
134
|
+
"allowOptionCreation"
|
|
128
135
|
]
|
|
129
136
|
},
|
|
130
137
|
{
|
|
@@ -155,6 +162,15 @@
|
|
|
155
162
|
}
|
|
156
163
|
],
|
|
157
164
|
"presets": [
|
|
165
|
+
{
|
|
166
|
+
"name": "Create tags",
|
|
167
|
+
"props": {
|
|
168
|
+
"allowOptionCreation": true,
|
|
169
|
+
"multiSelect": true,
|
|
170
|
+
"name": "tags[]",
|
|
171
|
+
"label": "Tags"
|
|
172
|
+
}
|
|
173
|
+
},
|
|
158
174
|
{
|
|
159
175
|
"name": "Async search",
|
|
160
176
|
"structureMode": "async",
|
|
@@ -198,7 +198,11 @@
|
|
|
198
198
|
"closeOnClick",
|
|
199
199
|
"constrainHeight",
|
|
200
200
|
"isClosed",
|
|
201
|
-
"
|
|
201
|
+
"enableFormSupport",
|
|
202
|
+
"requiredIndicator",
|
|
203
|
+
"required",
|
|
204
|
+
"validationMessage",
|
|
205
|
+
"allowOptionCreation"
|
|
202
206
|
]
|
|
203
207
|
},
|
|
204
208
|
{
|
|
@@ -226,6 +230,7 @@
|
|
|
226
230
|
}
|
|
227
231
|
],
|
|
228
232
|
"presets": [
|
|
233
|
+
{ "name": "Create tags", "props": { "allowOptionCreation": true, "multiSelect": true, "name": "tags[]", "label": "Tags" } },
|
|
229
234
|
{
|
|
230
235
|
"name": "Async search",
|
|
231
236
|
"structureMode": "async",
|
|
@@ -47,6 +47,7 @@ examples:
|
|
|
47
47
|
- dropdown_async_search: Async Search
|
|
48
48
|
- dropdown_async_multi_select_rails: Async Multi Select
|
|
49
49
|
- dropdown_async_custom_content: Async Search with Custom Content
|
|
50
|
+
- dropdown_option_creation: Allow Option Creation
|
|
50
51
|
|
|
51
52
|
react:
|
|
52
53
|
- dropdown_default: Default
|
|
@@ -100,4 +101,6 @@ examples:
|
|
|
100
101
|
- dropdown_async_rich_results: Async Search with Custom Content
|
|
101
102
|
- dropdown_async_initial_options: Async Initial Results and Caching
|
|
102
103
|
- dropdown_external_search: External Search
|
|
104
|
+
- dropdown_option_creation: Allow Option Creation
|
|
105
|
+
- dropdown_form: Enable Form Support
|
|
103
106
|
|
|
@@ -47,5 +47,7 @@ export { default as DropdownAsyncMultiSelect } from './_dropdown_async_multi_sel
|
|
|
47
47
|
export { default as DropdownAsyncRichResults } from './_dropdown_async_rich_results.jsx'
|
|
48
48
|
export { default as DropdownAsyncInitialOptions } from './_dropdown_async_initial_options.jsx'
|
|
49
49
|
export { default as DropdownExternalSearch } from './_dropdown_external_search.jsx'
|
|
50
|
+
export { default as DropdownForm } from "./_dropdown_form.jsx"
|
|
51
|
+
export { default as DropdownOptionCreation } from "./_dropdown_option_creation.jsx"
|
|
50
52
|
export { default as DropdownQuickpickEnableTime } from './_dropdown_quickpick_enable_time.jsx'
|
|
51
|
-
export { default as DropdownWithHighlight } from './_dropdown_with_highlight.jsx'
|
|
53
|
+
export { default as DropdownWithHighlight } from './_dropdown_with_highlight.jsx'
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
data-message="<%= object.validation_message %>"
|
|
20
20
|
<% end %>
|
|
21
21
|
data-dropdown-selected-option
|
|
22
|
-
name="<%= object.
|
|
22
|
+
name="<%= object.input_name %>"
|
|
23
23
|
style="display: none"
|
|
24
24
|
<%= object.disabled ? "disabled" : ""%>
|
|
25
25
|
<%= object.required ? "required" : ""%>
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
<%= pb_rails("body", props: { status: "negative", text: object.error, id: object.error_id, aria: { atomic: "true", live: "polite" }, html_options: { role: "alert" }, dark: object.dark }) %>
|
|
35
35
|
<% end %>
|
|
36
36
|
<% else %>
|
|
37
|
-
<%= pb_rails("dropdown/dropdown_trigger", props: { autocomplete: object.
|
|
37
|
+
<%= pb_rails("dropdown/dropdown_trigger", props: { autocomplete: object.autocomplete_enabled?, disabled: object.disabled, multi_select: object.multi_select, placeholder: object.placeholder, select_id: object.select_id, label: object.label, error_id: object.error_id, error: object.error }) %>
|
|
38
38
|
<%= pb_rails("dropdown/dropdown_container", props: { searchbar: object.searchbar, constrain_height: object.constrain_height, disabled: object.disabled, no_options_text: object.no_options_text }) do %>
|
|
39
39
|
<% if options_with_blank.present? %>
|
|
40
40
|
<% options_with_blank.each do |option| %>
|
|
@@ -29,6 +29,8 @@ module Playbook
|
|
|
29
29
|
default: true
|
|
30
30
|
prop :autocomplete, type: Playbook::Props::Boolean,
|
|
31
31
|
default: false
|
|
32
|
+
prop :allow_option_creation, type: Playbook::Props::Boolean,
|
|
33
|
+
default: false
|
|
32
34
|
prop :async, type: Playbook::Props::Boolean, default: false
|
|
33
35
|
prop :active_style, type: Playbook::Props::HashProp,
|
|
34
36
|
default: {}
|
|
@@ -80,6 +82,7 @@ module Playbook
|
|
|
80
82
|
Hash(prop(:data)).merge(
|
|
81
83
|
pb_dropdown: true,
|
|
82
84
|
pb_dropdown_async: async ? true : nil,
|
|
85
|
+
pb_dropdown_allow_option_creation: allow_option_creation && variant != "quickpick" ? true : nil,
|
|
83
86
|
pb_dropdown_active_style: active_style.present? ? active_style.to_json : nil,
|
|
84
87
|
pb_dropdown_default_value: async && default_value.present? ? default_value.to_json : nil,
|
|
85
88
|
pb_dropdown_search_term_minimum_length: async ? search_term_minimum_length : nil,
|
|
@@ -113,6 +116,17 @@ module Playbook
|
|
|
113
116
|
id.presence || (label.present? ? label.downcase.gsub(/\s+/, "_").gsub(/[^a-z0-9_]/, "") : nil)
|
|
114
117
|
end
|
|
115
118
|
|
|
119
|
+
def autocomplete_enabled?
|
|
120
|
+
autocomplete || (allow_option_creation && variant != "quickpick")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def input_name
|
|
124
|
+
return name unless multi_select
|
|
125
|
+
return name if name.present? && name.end_with?("[]")
|
|
126
|
+
|
|
127
|
+
"#{name}[]"
|
|
128
|
+
end
|
|
129
|
+
|
|
116
130
|
def error_id
|
|
117
131
|
error.present? ? "#{select_id || 'dropdown_trigger'}-error" : nil
|
|
118
132
|
end
|
|
@@ -2387,6 +2387,45 @@ describe('async Dropdown empty menu', () => {
|
|
|
2387
2387
|
});
|
|
2388
2388
|
|
|
2389
2389
|
|
|
2390
|
+
describe('autocomplete query after selection', () => {
|
|
2391
|
+
test.each([undefined, false])('plain uncontrolled autocomplete clears with allowOptionCreation=%s', (allowOptionCreation) => {
|
|
2392
|
+
const onSelect = jest.fn();
|
|
2393
|
+
render(<Dropdown allowOptionCreation={allowOptionCreation}
|
|
2394
|
+
autocomplete
|
|
2395
|
+
onSelect={onSelect}
|
|
2396
|
+
options={options}
|
|
2397
|
+
/>);
|
|
2398
|
+
const input = screen.getByRole('textbox');
|
|
2399
|
+
fireEvent.click(input);
|
|
2400
|
+
fireEvent.change(input, { target: { value: 'Can' } });
|
|
2401
|
+
fireEvent.click(screen.getByText('Canada'));
|
|
2402
|
+
|
|
2403
|
+
expect(input).toHaveValue('');
|
|
2404
|
+
expect(onSelect).toHaveBeenCalledWith(options[1]);
|
|
2405
|
+
});
|
|
2406
|
+
|
|
2407
|
+
test.each(['creation', 'async', 'controlled'])('%s autocomplete retains the selected label', (mode) => {
|
|
2408
|
+
const Example = () => {
|
|
2409
|
+
const [value, setValue] = useState(null);
|
|
2410
|
+
return (
|
|
2411
|
+
<Dropdown allowOptionCreation={mode === 'creation'}
|
|
2412
|
+
async={mode === 'async'}
|
|
2413
|
+
autocomplete
|
|
2414
|
+
onSelect={setValue}
|
|
2415
|
+
options={options}
|
|
2416
|
+
{...(mode === 'controlled' ? { value } : {})}
|
|
2417
|
+
/>
|
|
2418
|
+
);
|
|
2419
|
+
};
|
|
2420
|
+
render(<Example />);
|
|
2421
|
+
const input = screen.getByRole('textbox');
|
|
2422
|
+
fireEvent.click(input);
|
|
2423
|
+
fireEvent.change(input, { target: { value: 'Can' } });
|
|
2424
|
+
fireEvent.click(screen.getByText('Canada'));
|
|
2425
|
+
expect(input).toHaveValue('Canada');
|
|
2426
|
+
});
|
|
2427
|
+
});
|
|
2428
|
+
|
|
2390
2429
|
describe('autocomplete selection replacement', () => {
|
|
2391
2430
|
beforeEach(() => jest.useFakeTimers());
|
|
2392
2431
|
afterEach(() => { jest.clearAllTimers(); jest.useRealTimers(); });
|