playbook_ui 18.0.0.pre.alpha.play3194filtericons19569 → 18.0.0.pre.alpha.play316819703

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 (118) hide show
  1. checksums.yaml +4 -4
  2. data/app/pb_kits/playbook/pb_background/_background.tsx +4 -1
  3. data/app/pb_kits/playbook/pb_background/background.test.js +23 -1
  4. data/app/pb_kits/playbook/pb_copy_button/_copy_button.tsx +5 -1
  5. data/app/pb_kits/playbook/pb_copy_button/copy_button.test.jsx +12 -0
  6. data/app/pb_kits/playbook/pb_date_time_stacked/_date_time_stacked.tsx +10 -4
  7. data/app/pb_kits/playbook/pb_date_time_stacked/date_time_stacked.test.js +12 -0
  8. data/app/pb_kits/playbook/pb_dialog/child_kits/_dialog_body.tsx +9 -4
  9. data/app/pb_kits/playbook/pb_dialog/dialog.test.jsx +10 -0
  10. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_async_custom_content.html.erb +57 -0
  11. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_async_custom_content.md +10 -0
  12. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_async_search.html.erb +42 -0
  13. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_async_search.md +55 -0
  14. data/app/pb_kits/playbook/pb_dropdown/docs/example.yml +2 -0
  15. data/app/pb_kits/playbook/pb_dropdown/dropdown.rb +7 -0
  16. data/app/pb_kits/playbook/pb_dropdown/dropdown_index.test.js +773 -0
  17. data/app/pb_kits/playbook/pb_dropdown/index.js +238 -55
  18. data/app/pb_kits/playbook/pb_dropdown/keyboard_accessibility.js +5 -3
  19. data/app/pb_kits/playbook/pb_dropdown/kit.schema.json +19 -0
  20. data/app/pb_kits/playbook/pb_empty_state/_empty_state.tsx +5 -1
  21. data/app/pb_kits/playbook/pb_empty_state/empty_state.test.jsx +11 -0
  22. data/app/pb_kits/playbook/pb_filter/Filter/FilterBackground.tsx +8 -2
  23. data/app/pb_kits/playbook/pb_filter/Filter/FilterResponsive.tsx +116 -0
  24. data/app/pb_kits/playbook/pb_filter/Filter/SortMenu.tsx +1 -1
  25. data/app/pb_kits/playbook/pb_filter/Filter/index.tsx +48 -6
  26. data/app/pb_kits/playbook/pb_filter/_filter.scss +127 -0
  27. data/app/pb_kits/playbook/pb_filter/_filter.tsx +1 -0
  28. data/app/pb_kits/playbook/pb_filter/docs/_filter_responsive.html.erb +42 -0
  29. data/app/pb_kits/playbook/pb_filter/docs/_filter_responsive.jsx +76 -0
  30. data/app/pb_kits/playbook/pb_filter/docs/_filter_responsive.md +3 -0
  31. data/app/pb_kits/playbook/pb_filter/docs/_playground.json +2 -0
  32. data/app/pb_kits/playbook/pb_filter/docs/_playground.overrides.json +2 -0
  33. data/app/pb_kits/playbook/pb_filter/docs/example.yml +2 -0
  34. data/app/pb_kits/playbook/pb_filter/docs/index.js +1 -0
  35. data/app/pb_kits/playbook/pb_filter/filter.html.erb +139 -97
  36. data/app/pb_kits/playbook/pb_filter/filter.rb +23 -3
  37. data/app/pb_kits/playbook/pb_filter/filter.test.js +78 -0
  38. data/app/pb_kits/playbook/pb_filter/kit.schema.json +13 -2
  39. data/app/pb_kits/playbook/pb_form/docs/_form_model_values.html.erb +43 -0
  40. data/app/pb_kits/playbook/pb_form/docs/_form_model_values.md +22 -0
  41. data/app/pb_kits/playbook/pb_form/docs/example.yml +1 -0
  42. data/app/pb_kits/playbook/pb_multi_level_select/_helper_functions.tsx +12 -1
  43. data/app/pb_kits/playbook/pb_multi_level_select/_multi_level_select.tsx +2 -3
  44. data/app/pb_kits/playbook/pb_multi_level_select/multi_level_select.test.jsx +26 -0
  45. data/app/pb_kits/playbook/pb_multi_level_select/tree_helpers.js +10 -4
  46. data/app/pb_kits/playbook/pb_pill/_pill.test.jsx +43 -0
  47. data/app/pb_kits/playbook/pb_pill/_pill.tsx +12 -6
  48. data/app/pb_kits/playbook/pb_pill/docs/_pill_children.html.erb +9 -0
  49. data/app/pb_kits/playbook/pb_pill/docs/_pill_children.jsx +45 -0
  50. data/app/pb_kits/playbook/pb_pill/docs/_pill_children.md +1 -0
  51. data/app/pb_kits/playbook/pb_pill/docs/_playground.json +14 -1
  52. data/app/pb_kits/playbook/pb_pill/docs/_playground.overrides.json +17 -0
  53. data/app/pb_kits/playbook/pb_pill/docs/example.yml +2 -0
  54. data/app/pb_kits/playbook/pb_pill/docs/index.js +1 -0
  55. data/app/pb_kits/playbook/pb_pill/pill.html.erb +5 -1
  56. data/app/pb_kits/playbook/pb_progress_step/_progress_step_item.tsx +2 -1
  57. data/app/pb_kits/playbook/pb_progress_step/progress_step.test.js +16 -0
  58. data/app/pb_kits/playbook/pb_source/_source.tsx +14 -11
  59. data/app/pb_kits/playbook/pb_source/source.test.js +6 -0
  60. data/app/pb_kits/playbook/pb_table/_table.tsx +1 -1
  61. data/app/pb_kits/playbook/pb_table/docs/_table_header.jsx +193 -0
  62. data/app/pb_kits/playbook/pb_table/docs/{_table_header.md → _table_header_rails.md} +5 -3
  63. data/app/pb_kits/playbook/pb_table/docs/_table_header_react.md +15 -0
  64. data/app/pb_kits/playbook/pb_table/docs/_table_responsive_table.html.erb +75 -0
  65. data/app/pb_kits/playbook/pb_table/docs/_table_responsive_table.jsx +80 -0
  66. data/app/pb_kits/playbook/pb_table/docs/_table_responsive_table_rails.md +5 -0
  67. data/app/pb_kits/playbook/pb_table/docs/_table_responsive_table_react.md +5 -0
  68. data/app/pb_kits/playbook/pb_table/docs/example.yml +1 -0
  69. data/app/pb_kits/playbook/pb_table/docs/index.js +1 -0
  70. data/app/pb_kits/playbook/pb_table/subcomponents/_table_header.tsx +143 -12
  71. data/app/pb_kits/playbook/pb_table/table.rb +1 -1
  72. data/app/pb_kits/playbook/pb_table/table.test.js +134 -1
  73. data/app/pb_kits/playbook/pb_table/utilities/sortMenuHelpers.test.ts +76 -0
  74. data/app/pb_kits/playbook/pb_table/utilities/sortMenuHelpers.ts +84 -0
  75. data/dist/ai/all-schemas.json +244 -153
  76. data/dist/ai/forms.json +101 -99
  77. data/dist/ai/index.json +2 -2
  78. data/dist/ai/kits/button.schema.json +4 -3
  79. data/dist/ai/kits/checkbox.schema.json +7 -6
  80. data/dist/ai/kits/date_picker.schema.json +12 -10
  81. data/dist/ai/kits/dropdown.schema.json +81 -13
  82. data/dist/ai/kits/filter.schema.json +11 -0
  83. data/dist/ai/kits/multi_level_select.schema.json +13 -12
  84. data/dist/ai/kits/phone_number_input.schema.json +18 -17
  85. data/dist/ai/kits/select.schema.json +18 -17
  86. data/dist/ai/kits/star_rating.schema.json +10 -9
  87. data/dist/ai/kits/text_input.schema.json +35 -34
  88. data/dist/ai/kits/textarea.schema.json +10 -9
  89. data/dist/ai/kits/time_picker.schema.json +12 -11
  90. data/dist/ai/kits/typeahead.schema.json +13 -12
  91. data/dist/ai/playgrounds/index.json +1 -1
  92. data/dist/ai/playgrounds/pill.json +1 -1
  93. data/dist/ai/visual-index.json +1 -1
  94. data/dist/chunks/{_detail-Cy2bFz4f.js → _detail-BqqzPTNr.js} +2 -2
  95. data/dist/chunks/_table-D7wuBFbb.js +1 -0
  96. data/dist/chunks/_typeahead-KNcX62pc.js +1 -0
  97. data/dist/chunks/vendor.js +3 -3
  98. data/dist/playbook-rails-react-bindings.js +1 -1
  99. data/dist/playbook-rails.js +1 -1
  100. data/dist/playbook.css +1 -1
  101. data/lib/playbook/changelog_generator.rb +33 -3
  102. data/lib/playbook/forms/builder/attribute_defaults.rb +118 -0
  103. data/lib/playbook/forms/builder/collection_select_field.rb +3 -0
  104. data/lib/playbook/forms/builder/date_picker_field.rb +22 -7
  105. data/lib/playbook/forms/builder/dropdown_field.rb +12 -0
  106. data/lib/playbook/forms/builder/form_field_builder.rb +2 -0
  107. data/lib/playbook/forms/builder/intl_telephone_field.rb +8 -0
  108. data/lib/playbook/forms/builder/multi_level_select_field.rb +10 -0
  109. data/lib/playbook/forms/builder/phone_number_field.rb +8 -0
  110. data/lib/playbook/forms/builder/select_field.rb +3 -0
  111. data/lib/playbook/forms/builder/star_rating_field.rb +7 -0
  112. data/lib/playbook/forms/builder/time_picker_field.rb +8 -0
  113. data/lib/playbook/forms/builder/typeahead_field.rb +15 -0
  114. data/lib/playbook/forms/builder.rb +3 -0
  115. data/lib/playbook/version.rb +1 -1
  116. metadata +26 -6
  117. data/dist/chunks/_table-BiUZZExj.js +0 -1
  118. data/dist/chunks/_typeahead-BiNcC1AJ.js +0 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 330ed574153c52e60a6a982377e431077777c579faefe56364b21890f76a1e11
4
- data.tar.gz: 7a1993dd4be0cd0e4c95378b02017f0235382605363b8a619800efdb84185d1e
3
+ metadata.gz: d786f7aa30fa9c245ffabc812b1f4cc4eec3c066fa9e7e927ca02cde72bdbbf0
4
+ data.tar.gz: 5266e40cbf6df3c53f75013c6d8377bfdc66198703316aced89f93d7d2534321
5
5
  SHA512:
6
- metadata.gz: ee505f4628c702e0f5a18e8dc683a2a1782399cbb70e237e2c5f41cbe102eb8c6d26fa75e5e3d68cc3664bd619009146b630172c4f54c5ff22ebe86679f9faa8
7
- data.tar.gz: 71f9edfe85aa25da65d7edc908d9035c2f336d62f2c46008a56d1746301d27c707eea64cbe26119ad7d606f685df6621bd3b922c5c52dbe4c5d3bb671b8d06a0
6
+ metadata.gz: db68dc179c9f281f22230e7bbb95c2ecf72c74376c0a39b6d94cc5ddf6f5b86383302cb83c96e385cfa764edf0a11e752e8e19860ba4eacb70e2b320b3424ca9
7
+ data.tar.gz: b765470abaf7b8e2d16a74e01faad0e4092ab40773984e0127a2d62444b0040636c2e1547225700e9fd2616af079775a82c1457b3f7138cb47ec55e588a7df01
@@ -98,7 +98,7 @@ const Background = (props: BackgroundProps): React.ReactElement => {
98
98
  imageUrl: getResponsiveValue(imageUrl),
99
99
  });
100
100
 
101
- // Update responsive values on window resize.
101
+ // Keep responsive values in sync when props change, and on window resize.
102
102
  useEffect(() => {
103
103
  const updateResponsiveProps = () => {
104
104
  setResponsiveProps({
@@ -109,6 +109,7 @@ const Background = (props: BackgroundProps): React.ReactElement => {
109
109
  imageUrl: getResponsiveValue(imageUrl),
110
110
  });
111
111
  };
112
+ updateResponsiveProps();
112
113
  window.addEventListener('resize', updateResponsiveProps);
113
114
  return () => window.removeEventListener('resize', updateResponsiveProps);
114
115
  }, [backgroundSize, backgroundPosition, backgroundRepeat, backgroundColor, imageUrl]);
@@ -153,6 +154,8 @@ const Background = (props: BackgroundProps): React.ReactElement => {
153
154
  ...dynamicInlineProps
154
155
  };
155
156
 
157
+ // Dynamic HTML tag from props; matches other kits (Title, Collapsible, etc.).
158
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
156
159
  const Tag: React.ReactElement | any = `${tag}`;
157
160
  const ariaProps = buildAriaProps(aria);
158
161
  const dataProps = buildDataProps(data);
@@ -1,4 +1,5 @@
1
- import { ensureAccessible, renderKit } from '../utilities/test-utils'
1
+ import React from 'react'
2
+ import { ensureAccessible, render, renderKit } from '../utilities/test-utils'
2
3
 
3
4
  import Background from './_background'
4
5
 
@@ -20,6 +21,27 @@ test('backgroundColor = category_1', () => {
20
21
  expect(kit).toHaveClass('pb_background_kit pb_background_color_category_1')
21
22
  })
22
23
 
24
+ test('updates backgroundColor class when the prop changes', () => {
25
+ const { getByTestId, rerender } = render(
26
+ <Background
27
+ backgroundColor="info_subtle"
28
+ data={{ testid: 'background' }}
29
+ />
30
+ )
31
+
32
+ expect(getByTestId('background')).toHaveClass('pb_background_color_info_subtle')
33
+
34
+ rerender(
35
+ <Background
36
+ backgroundColor="card_light"
37
+ data={{ testid: 'background' }}
38
+ />
39
+ )
40
+
41
+ expect(getByTestId('background')).toHaveClass('pb_background_color_card_light')
42
+ expect(getByTestId('background')).not.toHaveClass('pb_background_color_info_subtle')
43
+ })
44
+
23
45
  test('customColor prop styles background color with a hex value', () => {
24
46
  const kit = renderKit(Background, props, { customColor: '#1d99a8' })
25
47
 
@@ -1,6 +1,6 @@
1
1
  import React from 'react'
2
2
  import classnames from 'classnames'
3
- import { buildAriaProps, buildCss, buildDataProps } from '../utilities/props'
3
+ import { buildAriaProps, buildCss, buildDataProps, buildHtmlProps } from '../utilities/props'
4
4
  import { globalProps } from '../utilities/globalProps'
5
5
 
6
6
  import Button from '../pb_button/_button'
@@ -13,6 +13,7 @@ type CopyButtonProps = {
13
13
  aria?: { [key: string]: string }
14
14
  className?: string
15
15
  data?: { [key: string]: string }
16
+ htmlOptions?: {[key: string]: string | number | boolean | (() => void)}
16
17
  id?: string
17
18
  from?: string
18
19
  text?: string
@@ -29,6 +30,7 @@ const CopyButton = (props: CopyButtonProps) => {
29
30
  className,
30
31
  data = {},
31
32
  from = '',
33
+ htmlOptions = {},
32
34
  id,
33
35
  text = 'Copy',
34
36
  timeout = 1000,
@@ -42,12 +44,14 @@ const CopyButton = (props: CopyButtonProps) => {
42
44
 
43
45
  const ariaProps = buildAriaProps(aria)
44
46
  const dataProps = buildDataProps(data)
47
+ const htmlProps = buildHtmlProps(htmlOptions)
45
48
  const classes = classnames(buildCss('pb_copy_button_kit'), globalProps(props), className)
46
49
 
47
50
  return (
48
51
  <div
49
52
  {...ariaProps}
50
53
  {...dataProps}
54
+ {...htmlProps}
51
55
  className={classes}
52
56
  id={id}
53
57
  >
@@ -86,3 +86,15 @@ test('passes text and tooltip props to button', () => {
86
86
  const tooltip = kit.querySelector('.pb_tooltip_kit')
87
87
  expect(tooltip).toBeInTheDocument()
88
88
  })
89
+
90
+ test('applies htmlOptions to the root element', () => {
91
+ render(
92
+ <CopyButton
93
+ data={{ testid: 'html-options-test' }}
94
+ htmlOptions={{ title: 'copy title' }}
95
+ value="copy"
96
+ />
97
+ )
98
+
99
+ expect(screen.getByTestId('html-options-test')).toHaveAttribute('title', 'copy title')
100
+ })
@@ -1,5 +1,6 @@
1
1
 
2
2
  import React from 'react'
3
+ import classnames from 'classnames'
3
4
 
4
5
  import { buildCss, buildHtmlProps } from '../utilities/props'
5
6
  import { deprecatedProps, globalProps } from '../utilities/globalProps'
@@ -11,6 +12,7 @@ import TimeStacked from '../pb_time_stacked/_time_stacked'
11
12
  import DateStacked from '../pb_date_stacked/_date_stacked'
12
13
 
13
14
  type DateTimeStackedProps = {
15
+ className?: string,
14
16
  htmlOptions?: {[key: string]: string | number | boolean | (() => void)},
15
17
  id?: string,
16
18
  date: Date,
@@ -27,20 +29,25 @@ const DateTimeStacked = (props: DateTimeStackedProps): React.ReactElement => {
27
29
  date,
28
30
  datetime,
29
31
  dark,
32
+ className,
30
33
  htmlOptions = {},
31
34
  timeZone = 'America/New_York',
32
35
  showCurrentYear = false,
33
36
  } = props
34
37
 
35
- const classes = buildCss('pb_date_time_stacked_kit', globalProps(props))
36
- const htmlProps = buildHtmlProps(htmlOptions)
38
+ const classes = classnames(
39
+ buildCss('pb_date_time_stacked_kit'),
40
+ globalProps(props),
41
+ className
42
+ )
37
43
 
38
44
  return (
39
45
  <Flex
46
+ htmlOptions={htmlOptions}
40
47
  inline={false}
41
48
  vertical="stretch"
42
- {...htmlProps}
43
49
  {...props}
50
+ className={classes}
44
51
  >
45
52
  <FlexItem>
46
53
  <DateStacked
@@ -58,7 +65,6 @@ const DateTimeStacked = (props: DateTimeStackedProps): React.ReactElement => {
58
65
  />
59
66
  <FlexItem>
60
67
  <TimeStacked
61
- className={classes}
62
68
  dark={dark}
63
69
  date={date || datetime}
64
70
  timeZone={timeZone}
@@ -74,3 +74,15 @@ test('hides current year by default', () => {
74
74
  expect(yearElement).toBeNull()
75
75
  }
76
76
  })
77
+
78
+ test('applies kit class and global props to the root', () => {
79
+ const kit = renderKit(DateTimeStacked, {
80
+ data: { testid: 'datetimestacked-root' },
81
+ datetime,
82
+ dark: false,
83
+ margin: 'md',
84
+ })
85
+
86
+ expect(kit).toHaveClass('pb_date_time_stacked_kit')
87
+ expect(kit).toHaveClass('m_md')
88
+ })
@@ -1,22 +1,27 @@
1
1
  import React from 'react'
2
2
  import classnames from 'classnames'
3
- import { buildCss } from '../../utilities/props'
3
+ import { buildCss, buildHtmlProps } from '../../utilities/props'
4
4
  import { globalProps } from '../../utilities/globalProps'
5
5
 
6
6
  type DialogBodyProps = {
7
7
  children: React.ReactNode | React.ReactNode[] | string,
8
8
  padding?: "xxs" | "xs" | "sm" | "md" | "lg" | "xl",
9
- className?: string
9
+ className?: string,
10
+ htmlOptions?: {[key: string]: string | number | boolean | (() => void)},
10
11
  }
11
12
 
12
13
  // Body component
13
14
  const DialogBody = (props: DialogBodyProps): React.ReactElement => {
14
- const { children, className } = props
15
+ const { children, className, htmlOptions = {} } = props
15
16
  const bodyCSS = buildCss("dialog_body")
16
17
  const bodySpacing = globalProps(props)
18
+ const htmlProps = buildHtmlProps(htmlOptions)
17
19
 
18
20
  return (
19
- <div className={classnames(bodyCSS, bodySpacing, className)}>
21
+ <div
22
+ {...htmlProps}
23
+ className={classnames(bodyCSS, bodySpacing, className)}
24
+ >
20
25
  {children}
21
26
  </div>
22
27
  )
@@ -268,3 +268,13 @@ describe('isNativeSelectMenuInteraction', () => {
268
268
  expect(isNativeSelectMenuInteraction(dialog, select)).toBe(false)
269
269
  })
270
270
  })
271
+
272
+ test("applies htmlOptions to Dialog.Body", () => {
273
+ render(
274
+ <Dialog.Body htmlOptions={{ title: "body title" }}>
275
+ {"Body content"}
276
+ </Dialog.Body>
277
+ )
278
+
279
+ expect(document.querySelector(".dialog_body")).toHaveAttribute("title", "body title")
280
+ })
@@ -0,0 +1,57 @@
1
+ <%= pb_rails("dropdown", props: {
2
+ id: "async-rich-user-dropdown",
3
+ label: "User",
4
+ name: "async_rich_user",
5
+ placeholder: "Try Emily or Michael",
6
+ autocomplete: true,
7
+ async: true,
8
+ options: [],
9
+ }) %>
10
+
11
+ <template id="async-user-option-template">
12
+ <%= pb_rails("flex", props: { orientation: "column", align: "start" }) do %>
13
+ <%= pb_rails("body", props: { text: "Name", data: { async_user_name: true } }) %>
14
+ <%= pb_rails("detail", props: { text: "Title and Department", data: { async_user_detail: true } }) %>
15
+ <% end %>
16
+ </template>
17
+
18
+ <script>
19
+ (() => {
20
+ const dropdown = document.getElementById("async-rich-user-dropdown");
21
+ const template = document.getElementById("async-user-option-template");
22
+ if (!dropdown || !template || dropdown.dataset.asyncExampleBound) return;
23
+ dropdown.dataset.asyncExampleBound = "true";
24
+
25
+ dropdown.addEventListener("pb:dropdown:search", (event) => {
26
+ const { searchingFor, setResults, setError } = event.detail;
27
+ const params = new URLSearchParams({
28
+ q: searchingFor,
29
+ limit: "10",
30
+ select: "firstName,lastName,company",
31
+ });
32
+
33
+ fetch(`https://dummyjson.com/users/search?${params}`, { credentials: "omit" })
34
+ .then((response) => {
35
+ if (!response.ok) throw new Error("User search failed");
36
+ return response.json();
37
+ })
38
+ .then(({ users }) => {
39
+ setResults(users.map((user) => {
40
+ const option = {
41
+ id: user.id,
42
+ label: `${user.firstName} ${user.lastName}`,
43
+ value: user.id,
44
+ title: user.company?.title || "",
45
+ department: user.company?.department || "",
46
+ };
47
+ const content = template.content.cloneNode(true);
48
+ content.querySelector("[data-async-user-name]").textContent = option.label;
49
+ content.querySelector("[data-async-user-detail]").textContent =
50
+ [option.title, option.department].filter(Boolean).join(" · ");
51
+ return { option, content };
52
+ }));
53
+ })
54
+ .catch(() => setError());
55
+ });
56
+ })();
57
+ </script>
@@ -0,0 +1,10 @@
1
+ For custom Rails result rows, pass `{ option, content }` entries to the async search event's `setResults` callback. Plain option objects and rich entries can appear in the same result array.
2
+
3
+ - `option` is the ordinary serializable Dropdown option object: `id`, `label`, `value`, and any custom data. `disabled: true` prevents selection.
4
+ - `content` is an `Element` or `DocumentFragment`, usually cloned from a Rails-rendered `<template>`. Dropdown clones it into its standard option wrapper; it does not consume or move your original nodes. Populate API text with `textContent`. HTML strings are not a rich-content input.
5
+
6
+ The explicit `option.label` is used in the selected input, even when the row contains additional text. Content is not serialized into option data or the existing `pb:dropdown:selected` payload. Nested content supports click selection and keyboard navigation, including when the menu is portaled.
7
+
8
+ Provide display content only: do not include another Dropdown option wrapper, interactive controls, or named form inputs in suggestions. Keep submitted fields outside the result content and update them from selection data. Cloning preserves markup and data attributes but does not copy JavaScript event listeners attached to the original nodes.
9
+
10
+ The example searches [DummyJSON’s public sample users API](https://dummyjson.com/docs/users) without credentials or an API key. Try `Emily` or `Michael`. It renders Rails Body and Detail kits for the name, job title, and department, retaining title and department in the selected option data. The same pattern works with a User kit or other custom display content.
@@ -0,0 +1,42 @@
1
+ <%= pb_rails("dropdown", props: {
2
+ id: "async-user-dropdown",
3
+ label: "User",
4
+ name: "async_user",
5
+ placeholder: "Try Emily or Michael",
6
+ autocomplete: true,
7
+ async: true,
8
+ search_term_minimum_length: 3,
9
+ search_debounce_timeout: 250,
10
+ options: [],
11
+ }) %>
12
+
13
+ <script>
14
+ (() => {
15
+ const dropdown = document.getElementById("async-user-dropdown");
16
+ if (!dropdown || dropdown.dataset.asyncExampleBound) return;
17
+ dropdown.dataset.asyncExampleBound = "true";
18
+
19
+ dropdown.addEventListener("pb:dropdown:search", (event) => {
20
+ const { searchingFor, setResults, setError } = event.detail;
21
+ const params = new URLSearchParams({
22
+ q: searchingFor,
23
+ limit: "10",
24
+ select: "firstName,lastName",
25
+ });
26
+
27
+ fetch(`https://dummyjson.com/users/search?${params}`, { credentials: "omit" })
28
+ .then((response) => {
29
+ if (!response.ok) throw new Error("User search failed");
30
+ return response.json();
31
+ })
32
+ .then(({ users }) => {
33
+ setResults(users.map((user) => ({
34
+ id: user.id,
35
+ label: `${user.firstName} ${user.lastName}`,
36
+ value: user.id,
37
+ })));
38
+ })
39
+ .catch(() => setError());
40
+ });
41
+ })();
42
+ </script>
@@ -0,0 +1,55 @@
1
+ Rails Dropdown supports event-driven remote search with `async: true` and either `autocomplete: true` or `searchbar: true`. Local Dropdown filtering remains unchanged when async is disabled.
2
+
3
+ Type at least `search_term_minimum_length` characters (default: `3`). After `search_debounce_timeout` milliseconds without another edit (default: `250`), the kit opens the menu, displays a loading message, and emits a bubbling `pb:dropdown:search` event from the Dropdown root. Scope listeners using the root's `id` or `data` attributes.
4
+
5
+ The event detail contains:
6
+
7
+ - `searchingFor`: the current query.
8
+ - `setResults(options)`: complete the search with an array of standard Dropdown option objects (`id`, `label`, `value`) or rich `{ option, content }` entries. Use an empty array for no matches.
9
+ - `setError()`: end loading and display a failure message.
10
+
11
+ The application owns fetching and mapping results. For example, call `fetch(yourUrl).then(...)`, check `response.ok`, map the response to options, pass them to `setResults`, and call `setError` on failure. This example searches [DummyJSON’s public sample users API](https://dummyjson.com/docs/users) over HTTPS without credentials or an API key. Try `Emily` or `Michael`. The API receives the search text and returns up to 10 results; only name fields are requested. HTTP or network failures display the kit’s error state.
12
+
13
+ Results are displayed without additional local label filtering and without clearing the typed query. Callbacks are request-scoped: subsequent edits, selection, clear/reset, dismissal with Escape/Tab or an outside click, and disconnect invalidate pending requests. Dismissing during the debounce delay cancels the search before it starts; completed results and status messages remain available when reopening. Only the first completion is accepted. Requests that do not complete within 15 seconds display a failure message. The kit ignores stale responses; it does not abort application-owned network requests.
14
+
15
+ For rich results, `content` is an Element or DocumentFragment cloned into the option wrapper; `option` supplies the label and selection data. See Async Search with Custom Content for a Rails template example.
16
+
17
+ Async Quick Pick is not supported; Quick Pick uses static date presets.
18
+
19
+ #### Selection data and defaults
20
+
21
+ `pb:dropdown:selected` bubbles from the Dropdown root after selection updates. Its existing detail shape is unchanged: the complete option object for single-select, an array for multi-select, and `null` / `[]` when cleared. Custom JSON-serializable fields, nested objects, booleans, and arrays are preserved; rendered rich content is not included. For example, read `event.detail.department_id` to update a dependent field. Numeric IDs retain their type in the event payload; form inputs submit strings.
22
+
23
+ Selected data is retained independently of remote result rows. A later search returning different options does not discard the selection or its metadata. In multi-select, previously selected IDs remain hidden when they reappear, even if the server returns updated labels or other fields. The selected payload stays as it was when selected until the selection is explicitly changed.
24
+
25
+ To initialize an async Dropdown before any results load, supply a complete `default_value` option object (an array for multi-select), including `id` and `label`. For example: `async: true, autocomplete: true, options: [], default_value: { id: 42, label: "Ada", department_id: 7 }`. When using `dropdown_field`, pass that explicit default: the builder cannot resolve an initial model ID from an empty options array.
26
+
27
+ #### Input and reset events
28
+
29
+ With `async: true`, `pb:dropdown:input` bubbles from the Dropdown root immediately on every edit, including text below the search minimum. Its detail is `{ value, reason }`:
30
+
31
+ - `reason: "input"`: the autocomplete or search-bar text was edited. `value` is the current text, without debouncing.
32
+ - `reason: "clear"`: the clear control or public `pb:dropdown:clear` command cleared the Dropdown. `value` is `""`.
33
+ - `reason: "reset"`: a native form reset cleared the Dropdown. `value` is `""`.
34
+
35
+ Selection changes continue to use `pb:dropdown:selected`; choosing an option does not emit an input event. For free-text forms such as a name search, maintain a separate field from both events:
36
+
37
+ ```javascript
38
+ dropdown.addEventListener("pb:dropdown:input", ({ detail }) => {
39
+ nameField.value = detail.value;
40
+ // Application-owned IDs can be invalidated on any edit.
41
+ userIdField.value = "";
42
+ });
43
+ dropdown.addEventListener("pb:dropdown:selected", ({ detail }) => {
44
+ nameField.value = detail?.label || "";
45
+ userIdField.value = detail?.id ?? "";
46
+ });
47
+ ```
48
+
49
+ Editing nonempty text retains the Dropdown's selected value until it is changed or cleared. Emptying a single-select autocomplete clears its selected ID and emits `pb:dropdown:selected` with `null`. Emptying a search-bar query does not discard the selected option; emptying a multi-select query does not discard selected pills.
50
+
51
+ Explicit clear and native form reset clear query text, selection, remote results, loading/error messages, and pending callbacks, and close the menu. They emit the normal cleared selection payload (`null` or `[]`) and the input notification. Native reset completes after the browser resets form controls; a canceled reset leaves kit state intact. Reset clears the selection rather than restoring `default_value`, matching the kit's existing reset convention.
52
+
53
+ After clear, clicking an async autocomplete does not open an empty menu. The menu opens when a search starts or results/status content is available. An async search-bar menu can still open so its input remains accessible.
54
+
55
+ The clear control is also available for an unselected async query unless `clearable: false`. No async input notifications, remote-result cleanup, or changed reset timing are applied to synchronous Dropdowns.
@@ -2,6 +2,8 @@ examples:
2
2
  rails:
3
3
  - dropdown_default_rails: Default
4
4
  - dropdown_with_autocomplete: Autocomplete
5
+ - dropdown_async_search: Async Search
6
+ - dropdown_async_custom_content: Async Search with Custom Content
5
7
  - dropdown_multi_select_rails: Multi Select
6
8
  - dropdown_multi_select_with_autocomplete: Multi Select with Autocomplete
7
9
  - dropdown_multi_select_display_rails: Multi Select with Form Pill Props
@@ -27,6 +27,9 @@ module Playbook
27
27
  default: true
28
28
  prop :autocomplete, type: Playbook::Props::Boolean,
29
29
  default: false
30
+ prop :async, type: Playbook::Props::Boolean, default: false
31
+ prop :search_term_minimum_length, default: 3
32
+ prop :search_debounce_timeout, default: 250
30
33
  prop :searchbar, type: Playbook::Props::Boolean,
31
34
  default: false
32
35
  prop :multi_select, type: Playbook::Props::Boolean,
@@ -71,6 +74,10 @@ module Playbook
71
74
  def data
72
75
  Hash(prop(:data)).merge(
73
76
  pb_dropdown: true,
77
+ pb_dropdown_async: async ? true : nil,
78
+ pb_dropdown_default_value: async && default_value.present? ? default_value.to_json : nil,
79
+ pb_dropdown_search_term_minimum_length: async ? search_term_minimum_length : nil,
80
+ pb_dropdown_search_debounce_timeout: async ? search_debounce_timeout : nil,
74
81
  pb_dropdown_multi_select: multi_select,
75
82
  pb_dropdown_disabled: disabled,
76
83
  pb_dropdown_variant: variant,