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.
Files changed (129) hide show
  1. checksums.yaml +4 -4
  2. data/app/pb_kits/playbook/pb_dropdown/_dropdown.tsx +110 -15
  3. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_form.jsx +50 -0
  4. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_form_react.md +9 -0
  5. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_option_creation.html.erb +16 -0
  6. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_option_creation.jsx +27 -0
  7. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_option_creation_rails.md +9 -0
  8. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_option_creation_react.md +9 -0
  9. data/app/pb_kits/playbook/pb_dropdown/docs/_playground.json +17 -1
  10. data/app/pb_kits/playbook/pb_dropdown/docs/_playground.overrides.json +6 -1
  11. data/app/pb_kits/playbook/pb_dropdown/docs/example.yml +3 -0
  12. data/app/pb_kits/playbook/pb_dropdown/docs/index.js +3 -1
  13. data/app/pb_kits/playbook/pb_dropdown/dropdown.html.erb +2 -2
  14. data/app/pb_kits/playbook/pb_dropdown/dropdown.rb +14 -0
  15. data/app/pb_kits/playbook/pb_dropdown/dropdown.test.jsx +39 -0
  16. data/app/pb_kits/playbook/pb_dropdown/dropdown_creation.test.jsx +414 -0
  17. data/app/pb_kits/playbook/pb_dropdown/dropdown_form.test.jsx +173 -0
  18. data/app/pb_kits/playbook/pb_dropdown/dropdown_index.test.js +291 -0
  19. data/app/pb_kits/playbook/pb_dropdown/hooks/useHandleOnKeydown.tsx +22 -5
  20. data/app/pb_kits/playbook/pb_dropdown/index.js +94 -30
  21. data/app/pb_kits/playbook/pb_dropdown/keyboard_accessibility.js +3 -1
  22. data/app/pb_kits/playbook/pb_dropdown/kit.schema.json +18 -2
  23. data/app/pb_kits/playbook/pb_dropdown/subcomponents/DropdownContainer.tsx +43 -25
  24. data/app/pb_kits/playbook/pb_dropdown/subcomponents/DropdownFormFields.tsx +83 -0
  25. data/app/pb_kits/playbook/pb_dropdown/subcomponents/DropdownOption.tsx +13 -5
  26. data/app/pb_kits/playbook/pb_dropdown/utilities/optionCreation.ts +21 -0
  27. data/app/pb_kits/playbook/pb_dropdown/utilities/quickpickDateSync.test.ts +24 -0
  28. data/app/pb_kits/playbook/pb_filter/Filter/SortMenu.tsx +8 -1
  29. data/app/pb_kits/playbook/pb_filter/docs/_filter_one_click.html.erb +38 -0
  30. data/app/pb_kits/playbook/pb_filter/docs/_filter_one_click.jsx +69 -0
  31. data/app/pb_kits/playbook/pb_filter/docs/_filter_one_click.md +1 -0
  32. data/app/pb_kits/playbook/pb_filter/docs/example.yml +2 -0
  33. data/app/pb_kits/playbook/pb_filter/docs/index.js +2 -1
  34. data/app/pb_kits/playbook/pb_filter/filter.html.erb +4 -4
  35. data/app/pb_kits/playbook/pb_filter/filter.rb +28 -0
  36. data/app/pb_kits/playbook/pb_filter/filter.test.js +41 -0
  37. data/app/pb_kits/playbook/pb_form/docs/_description_react.md +3 -0
  38. data/app/pb_kits/playbook/pb_form/docs/_form_default_values.jsx +898 -0
  39. data/app/pb_kits/playbook/pb_form/docs/_form_default_values_react.md +3 -0
  40. data/app/pb_kits/playbook/pb_form/docs/_form_defaults.jsx +879 -0
  41. data/app/pb_kits/playbook/pb_form/docs/_form_defaults_react.md +29 -0
  42. data/app/pb_kits/playbook/pb_form/docs/_form_loading.jsx +75 -0
  43. data/app/pb_kits/playbook/pb_form/docs/{_form_loading.md → _form_loading_rails.md} +1 -0
  44. data/app/pb_kits/playbook/pb_form/docs/_form_loading_react.md +1 -0
  45. data/app/pb_kits/playbook/pb_form/docs/_form_model_values.jsx +147 -0
  46. data/app/pb_kits/playbook/pb_form/docs/_form_model_values_react.md +16 -0
  47. data/app/pb_kits/playbook/pb_form/docs/_form_multi_column.jsx +164 -0
  48. data/app/pb_kits/playbook/pb_form/docs/_form_multi_column_react.md +3 -0
  49. data/app/pb_kits/playbook/pb_form/docs/_form_validation.jsx +1057 -0
  50. data/app/pb_kits/playbook/pb_form/docs/_form_validation_react.md +26 -0
  51. data/app/pb_kits/playbook/pb_form/docs/example.yml +7 -0
  52. data/app/pb_kits/playbook/pb_full_screen/_full_screen.scss +6 -0
  53. data/app/pb_kits/playbook/pb_full_screen/_full_screen.tsx +11 -2
  54. data/app/pb_kits/playbook/pb_full_screen/docs/_full_screen_advanced_table_sticky_header.jsx +73 -0
  55. data/app/pb_kits/playbook/pb_full_screen/docs/_full_screen_advanced_table_sticky_header.md +1 -0
  56. data/app/pb_kits/playbook/pb_full_screen/docs/example.yml +1 -0
  57. data/app/pb_kits/playbook/pb_full_screen/docs/index.js +1 -0
  58. data/app/pb_kits/playbook/pb_full_screen/full_screen.test.jsx +4 -2
  59. data/app/pb_kits/playbook/pb_label_pill/_label_pill.tsx +3 -1
  60. data/app/pb_kits/playbook/pb_label_pill/kit.schema.json +1 -0
  61. data/app/pb_kits/playbook/pb_legend/_legend.tsx +3 -1
  62. data/app/pb_kits/playbook/pb_legend/kit.schema.json +1 -0
  63. data/app/pb_kits/playbook/pb_online_status/_online_status.tsx +3 -1
  64. data/app/pb_kits/playbook/pb_online_status/kit.schema.json +1 -0
  65. data/app/pb_kits/playbook/pb_pill/_pill.tsx +3 -1
  66. data/app/pb_kits/playbook/pb_pill/kit.schema.json +1 -0
  67. data/app/pb_kits/playbook/pb_progress_step/_progress_step_item.tsx +3 -1
  68. data/app/pb_kits/playbook/pb_rich_text_editor/docs/_rich_text_editor_advanced_required_indicator.md +2 -2
  69. data/app/pb_kits/playbook/pb_rich_text_editor/docs/_rich_text_editor_rails_required_indicator.html.erb +9 -0
  70. data/app/pb_kits/playbook/pb_rich_text_editor/docs/_rich_text_editor_rails_required_indicator.md +3 -0
  71. data/app/pb_kits/playbook/pb_rich_text_editor/docs/example.yml +1 -0
  72. data/app/pb_kits/playbook/pb_rich_text_editor/rich_text_editor.html.erb +3 -1
  73. data/app/pb_kits/playbook/pb_source/_source.tsx +3 -1
  74. data/app/pb_kits/playbook/pb_source/kit.schema.json +1 -0
  75. data/app/pb_kits/playbook/pb_stat_change/_stat_change.tsx +3 -1
  76. data/app/pb_kits/playbook/pb_stat_change/kit.schema.json +1 -0
  77. data/app/pb_kits/playbook/pb_timeline/_item.tsx +3 -1
  78. data/app/pb_kits/playbook/pb_typeahead/_typeahead.scss +73 -0
  79. data/app/pb_kits/playbook/pb_typeahead/_typeahead.test.jsx +158 -12
  80. data/app/pb_kits/playbook/pb_typeahead/_typeahead.tsx +3 -3
  81. data/app/pb_kits/playbook/pb_typeahead/components/PillRenderer.tsx +15 -11
  82. data/app/pb_kits/playbook/pb_typeahead/components/ValueContainer.tsx +29 -30
  83. data/app/pb_kits/playbook/pb_typeahead/components/usePillDropIndicator.ts +155 -0
  84. data/app/pb_kits/playbook/pb_typeahead/docs/_playground.json +5 -5
  85. data/app/pb_kits/playbook/pb_typeahead/docs/_playground.overrides.json +2 -2
  86. data/app/pb_kits/playbook/pb_typeahead/docs/_typeahead_draggable_pills.html.erb +0 -1
  87. data/app/pb_kits/playbook/pb_typeahead/docs/_typeahead_draggable_pills.jsx +0 -1
  88. data/app/pb_kits/playbook/pb_typeahead/docs/_typeahead_draggable_pills_rails.md +3 -3
  89. data/app/pb_kits/playbook/pb_typeahead/docs/_typeahead_draggable_pills_react.md +7 -0
  90. data/app/pb_kits/playbook/pb_typeahead/kit.schema.json +3 -3
  91. data/app/pb_kits/playbook/pb_typeahead/typeahead.rb +3 -3
  92. data/app/pb_kits/playbook/utilities/test/globalProps/onClick.test.js +116 -0
  93. data/dist/ai/all-schemas.json +53 -14
  94. data/dist/ai/forms.json +2 -2
  95. data/dist/ai/index.json +2 -2
  96. data/dist/ai/kits/dropdown.schema.json +44 -11
  97. data/dist/ai/kits/label_pill.schema.json +1 -0
  98. data/dist/ai/kits/legend.schema.json +1 -0
  99. data/dist/ai/kits/online_status.schema.json +1 -0
  100. data/dist/ai/kits/pill.schema.json +1 -0
  101. data/dist/ai/kits/source.schema.json +1 -0
  102. data/dist/ai/kits/stat_change.schema.json +1 -0
  103. data/dist/ai/kits/typeahead.schema.json +3 -3
  104. data/dist/ai/playgrounds/dropdown.json +1 -1
  105. data/dist/ai/playgrounds/index.json +1 -1
  106. data/dist/ai/playgrounds/typeahead.json +1 -1
  107. data/dist/chunks/_pagination-D0hcdUl3.js +1 -0
  108. data/dist/chunks/{_pb_line_graph-Du7xE9vl.js → _pb_line_graph-BKwbOeqo.js} +1 -1
  109. data/dist/chunks/{_detail-Bsvl694i.js → _tooltip-DfZEKsnv.js} +2 -2
  110. data/dist/chunks/_typeahead-CmJ8Ggzz.js +1 -0
  111. data/dist/chunks/{globalProps-B120NbSJ.js → globalProps-BivjwRew.js} +1 -1
  112. data/dist/chunks/lib-C50eCpoN.js +29 -0
  113. data/dist/chunks/{time_picker_helper-CYGg0UQ0.js → time_picker_helper-DfKvHCJu.js} +1 -1
  114. data/dist/chunks/vendor.js +2 -2
  115. data/dist/playbook-rails-react-bindings.js +1 -1
  116. data/dist/playbook-rails.js +1 -1
  117. data/dist/playbook.css +1 -1
  118. data/lib/playbook/version.rb +2 -2
  119. metadata +53 -18
  120. data/app/pb_kits/playbook/pb_typeahead/docs/_typeahead_draggable_pills.md +0 -7
  121. data/dist/chunks/_table-Bf6LGyL9.js +0 -1
  122. data/dist/chunks/_typeahead-w6S5Vx4b.js +0 -1
  123. data/dist/chunks/lib-Buo6zHEw.js +0 -29
  124. /data/app/pb_kits/playbook/pb_form/docs/{_description.md → _description_rails.md} +0 -0
  125. /data/app/pb_kits/playbook/pb_form/docs/{_form_default_values.md → _form_default_values_rails.md} +0 -0
  126. /data/app/pb_kits/playbook/pb_form/docs/{_form_defaults.md → _form_defaults_rails.md} +0 -0
  127. /data/app/pb_kits/playbook/pb_form/docs/{_form_model_values.md → _form_model_values_rails.md} +0 -0
  128. /data/app/pb_kits/playbook/pb_form/docs/{_form_multi_column.md → _form_multi_column_rails.md} +0 -0
  129. /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: b5ac75f8f899c8e692ebc15d9e4c233540f1b055707c362099ca5f6bd23da085
4
- data.tar.gz: 96f74ff346de6735599cbd46bf9d8b81e541b7ad7b258275e4174a922fe637ac
3
+ metadata.gz: dce454b27ec0be36f4812b6955579e934aa2d63d856a62d796e7ef64b05b0a94
4
+ data.tar.gz: 56f6845557ded84094f8757e2a29f3151a03407430f478ad6570aaf8dca25e93
5
5
  SHA512:
6
- metadata.gz: c8dc8cf0775094f3d738ff9fdb1bc5a7101d9f950c6503dd75c598da03e0a4353d9466a864947fbc905a42997a60f3e8ee29569179106b9c8fc796feb2fe6819
7
- data.tar.gz: 119a30f2687af34eb8bfbc1c942182d128838437cb8c15f71a061600892d9fe5498fe400db526ee306120e4b90f220d0f97bb1467e829191e6b5fe9c35a09eaf
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 filteredOptions = useMemo(() => {
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
- // Async autocomplete shows the selection in the input, as controlled mode and Rails do
584
- setFilterItem(isControlled && autocomplete
585
- ? (selected as GenericObject)?.label || ""
586
- : asyncEnabled && autocomplete ? clickedItem.label || "" : "");
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: asyncEnabled ? (loading || asyncStatus === "loading" ? "Loading…" : asyncStatus === "error" ? "Unable to load options" : asyncEmptyCopy) : "",
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
- "requiredIndicator"
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
- "requiredIndicator"
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.name %><%= '[]' if object.multi_select %>"
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.autocomplete, 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 }) %>
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(); });