diff --git a/playbook/app/pb_kits/playbook/pb_dropdown/_dropdown.tsx b/playbook/app/pb_kits/playbook/pb_dropdown/_dropdown.tsx
index cb5d53c031..7e3f13b1a0 100644
--- a/playbook/app/pb_kits/playbook/pb_dropdown/_dropdown.tsx
+++ b/playbook/app/pb_kits/playbook/pb_dropdown/_dropdown.tsx
@@ -112,6 +112,8 @@ type DropdownProps = {
isClosed?: boolean;
label?: string;
multiSelect?: boolean;
+ name?: string;
+ onChange?: (event: { target: { name?: string; value: any } }) => void;
onSelect?: (arg: GenericObject) => null;
options?: GenericObject;
placeholder?: string;
@@ -156,6 +158,8 @@ let Dropdown = (props: DropdownProps, ref: any): React.ReactElement | null => {
label,
multiSelect = false,
formPillProps,
+ name,
+ onChange,
onSelect,
options,
placeholder,
@@ -414,6 +418,10 @@ let Dropdown = (props: DropdownProps, ref: any): React.ReactElement | null => {
setIsDropDownClosed(false);
};
+ const handleSelectionChange = (value: any) => {
+ onSelect && onSelect(value);
+ onChange && onChange({ target: { name, value } });
+ };
const handleOptionClick = (clickedItem: GenericObject) => {
if (disabled) return;
@@ -426,7 +434,7 @@ let Dropdown = (props: DropdownProps, ref: any): React.ReactElement | null => {
const next = exists
? list.filter((option) => option.value !== clickedItem.value)
: [...list, clickedItem];
- onSelect && onSelect(next);
+ handleSelectionChange(next);
return next;
});
setFilterItem("");
@@ -439,7 +447,7 @@ let Dropdown = (props: DropdownProps, ref: any): React.ReactElement | null => {
if (shouldCloseOnClick) {
setIsDropDownClosed(true);
}
- onSelect && onSelect(clickedItem);
+ handleSelectionChange(clickedItem);
// Sync with DatePickers if this is a quickpick variant
if (variant === "quickpick" && Array.isArray(clickedItem.value)) {
@@ -468,10 +476,10 @@ let Dropdown = (props: DropdownProps, ref: any): React.ReactElement | null => {
if (disabled) return;
if (multiSelect) {
setSelected([]);
- onSelect && onSelect([]);
+ handleSelectionChange([]);
} else {
setSelected({});
- onSelect && onSelect(null);
+ handleSelectionChange(null);
setFocusedOptionIndex(-1);
setFilterItem("");
@@ -505,10 +513,10 @@ let Dropdown = (props: DropdownProps, ref: any): React.ReactElement | null => {
clearSelected: () => {
if (multiSelect) {
setSelected([]);
- onSelect && onSelect([]);
+ handleSelectionChange([]);
} else {
setSelected({});
- onSelect && onSelect(null);
+ handleSelectionChange(null);
}
setFilterItem("");
setIsDropDownClosed(true);
@@ -522,16 +530,16 @@ let Dropdown = (props: DropdownProps, ref: any): React.ReactElement | null => {
clearSelected: () => {
if (multiSelect) {
setSelected([]);
- onSelect && onSelect([]);
+ handleSelectionChange([]);
} else {
setSelected({});
- onSelect && onSelect(null);
+ handleSelectionChange(null);
}
setFilterItem("");
setIsDropDownClosed(true);
},
};
- }, [multiSelect, onSelect, setSelected, setFilterItem, setIsDropDownClosed]);
+ }, [multiSelect, handleSelectionChange, setSelected, setFilterItem, setIsDropDownClosed]);
useImperativeHandle(ref, () => imperativeRef.current);
@@ -594,6 +602,7 @@ let Dropdown = (props: DropdownProps, ref: any): React.ReactElement | null => {
handleBackspace,
handleChange,
handleOptionClick,
+ handleSelectionChange,
handleWrapperClick,
inputRef,
inputWrapperRef,
diff --git a/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_dynamic_options.html.erb b/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_dynamic_options.html.erb
new file mode 100644
index 0000000000..0768ad9d49
--- /dev/null
+++ b/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_dynamic_options.html.erb
@@ -0,0 +1,34 @@
+<%= pb_rails("select", props: {
+ id: "color_context_dropdown",
+ label: "Choose a Color",
+ name: "color_name",
+ options: [
+ { value: "red", value_text: "Red" },
+ { value: "blue", value_text: "Blue" },
+ { value: "green", value_text: "Green" },
+ ],
+}) %>
+
+<%= pb_rails("dropdown", props: {
+ id: "dropdown-dynamic-options",
+ label: "Pick a Shade",
+ name: "shade_name",
+ context_selector: "color_context_dropdown",
+ options_by_context: {
+ "red" => [
+ { id: "scarlet", label: "Scarlet", value: "scarlet" },
+ { id: "mahogany", label: "Mahogany", value: "mahogany" },
+ { id: "crimson", label: "Crimson", value: "crimson" },
+ ],
+ "blue" => [
+ { id: "sky", label: "Sky Blue", value: "sky" },
+ { id: "cerulean", label: "Cerulean", value: "cerulean" },
+ { id: "navy", label: "Navy", value: "navy" },
+ ],
+ "green" => [
+ { id: "emerald", label: "Emerald", value: "emerald" },
+ { id: "mint", label: "Mint", value: "mint" },
+ { id: "olive", label: "Olive", value: "olive" },
+ ],
+ },
+}) %>
diff --git a/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_dynamic_options.md b/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_dynamic_options.md
new file mode 100644
index 0000000000..447d4a1ac7
--- /dev/null
+++ b/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_dynamic_options.md
@@ -0,0 +1,4 @@
+You can set up a dropdown to update its options dynamically based on another input. To achieve this:
+- Give the dropdown a unique `id` so it can be targeted by events and linked to a controlling input.
+- Use `context_selector` to point at the controlling select’s `id`. On connect, and whenever that select’s value changes, the Dropdown reads the current value as its context.
+- Use `options_by_context` to pass a hash of option lists. Keys must match the possible values of the controlling select; each key maps to an array of `{ id, label, value }` options that replace the Dropdown’s options for that context.
diff --git a/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_dynamic_options_with_autocomplete.html.erb b/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_dynamic_options_with_autocomplete.html.erb
new file mode 100644
index 0000000000..d0cd2cbcb5
--- /dev/null
+++ b/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_dynamic_options_with_autocomplete.html.erb
@@ -0,0 +1,35 @@
+<%= pb_rails("select", props: {
+ id: "color_context_dropdown_autocomplete",
+ label: "Choose a Color",
+ name: "color_name_autocomplete",
+ options: [
+ { value: "red", value_text: "Red" },
+ { value: "blue", value_text: "Blue" },
+ { value: "green", value_text: "Green" },
+ ],
+}) %>
+
+<%= pb_rails("dropdown", props: {
+ id: "dropdown-dynamic-options-autocomplete",
+ label: "Pick a Shade",
+ name: "shade_name_autocomplete",
+ autocomplete: true,
+ context_selector: "color_context_dropdown_autocomplete",
+ options_by_context: {
+ "red" => [
+ { id: "scarlet", label: "Scarlet", value: "scarlet" },
+ { id: "mahogany", label: "Mahogany", value: "mahogany" },
+ { id: "crimson", label: "Crimson", value: "crimson" },
+ ],
+ "blue" => [
+ { id: "sky", label: "Sky Blue", value: "sky" },
+ { id: "cerulean", label: "Cerulean", value: "cerulean" },
+ { id: "navy", label: "Navy", value: "navy" },
+ ],
+ "green" => [
+ { id: "emerald", label: "Emerald", value: "emerald" },
+ { id: "mint", label: "Mint", value: "mint" },
+ { id: "olive", label: "Olive", value: "olive" },
+ ],
+ },
+}) %>
diff --git a/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_dynamic_options_with_autocomplete.md b/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_dynamic_options_with_autocomplete.md
new file mode 100644
index 0000000000..dc564404ad
--- /dev/null
+++ b/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_dynamic_options_with_autocomplete.md
@@ -0,0 +1 @@
+Dynamic options also work with `autocomplete`. Use the same `context_selector` and `options_by_context` setup as Dynamic Options, and set `autocomplete: true` so the Dropdown can be filtered by typing.
\ No newline at end of file
diff --git a/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_dynamic_options_with_multi_select.html.erb b/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_dynamic_options_with_multi_select.html.erb
new file mode 100644
index 0000000000..e761ace4ff
--- /dev/null
+++ b/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_dynamic_options_with_multi_select.html.erb
@@ -0,0 +1,35 @@
+<%= pb_rails("select", props: {
+ id: "color_context_dropdown_multi",
+ label: "Choose a Color",
+ name: "color_name_multi",
+ options: [
+ { value: "red", value_text: "Red" },
+ { value: "blue", value_text: "Blue" },
+ { value: "green", value_text: "Green" },
+ ],
+}) %>
+
+<%= pb_rails("dropdown", props: {
+ id: "dropdown-dynamic-options-multi",
+ label: "Pick Shades",
+ name: "shade_names",
+ multi_select: true,
+ context_selector: "color_context_dropdown_multi",
+ options_by_context: {
+ "red" => [
+ { id: "scarlet", label: "Scarlet", value: "scarlet" },
+ { id: "mahogany", label: "Mahogany", value: "mahogany" },
+ { id: "crimson", label: "Crimson", value: "crimson" },
+ ],
+ "blue" => [
+ { id: "sky", label: "Sky Blue", value: "sky" },
+ { id: "cerulean", label: "Cerulean", value: "cerulean" },
+ { id: "navy", label: "Navy", value: "navy" },
+ ],
+ "green" => [
+ { id: "emerald", label: "Emerald", value: "emerald" },
+ { id: "mint", label: "Mint", value: "mint" },
+ { id: "olive", label: "Olive", value: "olive" },
+ ],
+ },
+}) %>
diff --git a/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_dynamic_options_with_multi_select.md b/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_dynamic_options_with_multi_select.md
new file mode 100644
index 0000000000..36260441ee
--- /dev/null
+++ b/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_dynamic_options_with_multi_select.md
@@ -0,0 +1 @@
+Dynamic options also work with `multi_select`. Use the same `context_selector` and `options_by_context` setup as Dynamic Options, and set `multi_select: true` to allow selecting multiple options.
diff --git a/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_multi_select_react_hook.jsx b/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_multi_select_react_hook.jsx
new file mode 100644
index 0000000000..55a2485f99
--- /dev/null
+++ b/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_multi_select_react_hook.jsx
@@ -0,0 +1,45 @@
+import React from 'react'
+
+import Dropdown from '../_dropdown'
+import Title from '../../pb_title/_title'
+import { useForm } from 'react-hook-form'
+
+const options = [
+ { label: 'United States', value: 'unitedStates', id: 'us' },
+ { label: 'United Kingdom', value: 'unitedKingdom', id: 'gb' },
+ { label: 'Canada', value: 'canada', id: 'ca' },
+ { label: 'Pakistan', value: 'pakistan', id: 'pk' },
+ { label: 'India', value: 'india', id: 'in' },
+ { label: 'Australia', value: 'australia', id: 'au' },
+ { label: 'New Zealand', value: 'new Zealand', id: 'nz' },
+ { label: 'Italy', value: 'italy', id: 'it' },
+ { label: 'Spain', value: 'spain', id: 'es' },
+]
+
+const DropdownMultiSelectReactHook = (props) => {
+ const { register, watch } = useForm()
+
+ const selectedCountries = watch('countries')
+
+ return (
+ <>
+
+
+ {selectedCountries && selectedCountries.map(country => (
+ {`${country.label} - ${country.value}`}
+ ))}
+ >
+ )
+}
+
+export default DropdownMultiSelectReactHook
diff --git a/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_multi_select_react_hook.md b/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_multi_select_react_hook.md
new file mode 100644
index 0000000000..41f34c1df5
--- /dev/null
+++ b/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_multi_select_react_hook.md
@@ -0,0 +1 @@
+You can pass `react-hook-form` props to a multi-select Dropdown. Spread `register` onto Dropdown with `multiSelect` to keep the selected options array in form state.
diff --git a/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_react_hook.jsx b/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_react_hook.jsx
new file mode 100644
index 0000000000..b3bd5f8e6a
--- /dev/null
+++ b/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_react_hook.jsx
@@ -0,0 +1,36 @@
+import React from 'react'
+
+import Dropdown from '../_dropdown'
+import Title from '../../pb_title/_title'
+import { useForm } from 'react-hook-form'
+
+const options = [
+ { label: 'United States', value: 'unitedStates', id: 'us' },
+ { label: 'Canada', value: 'canada', id: 'ca' },
+ { label: 'Pakistan', value: 'pakistan', id: 'pk' },
+]
+
+const DropdownReactHook = (props) => {
+ const { register, watch } = useForm()
+
+ const selectedCountry = watch('country')
+
+ return (
+ <>
+
+
+ {selectedCountry && `${selectedCountry.label} - ${selectedCountry.value}`}
+ >
+ )
+}
+
+export default DropdownReactHook
diff --git a/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_react_hook.md b/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_react_hook.md
new file mode 100644
index 0000000000..4d34d51c78
--- /dev/null
+++ b/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_react_hook.md
@@ -0,0 +1 @@
+You can pass `react-hook-form` props to the Dropdown kit. Spread `register` onto a single-select Dropdown to keep the selected option in form state.
diff --git a/playbook/app/pb_kits/playbook/pb_dropdown/docs/_playground.json b/playbook/app/pb_kits/playbook/pb_dropdown/docs/_playground.json
index fdd2745a46..42a9569feb 100644
--- a/playbook/app/pb_kits/playbook/pb_dropdown/docs/_playground.json
+++ b/playbook/app/pb_kits/playbook/pb_dropdown/docs/_playground.json
@@ -88,6 +88,7 @@
"name": "Content",
"props": [
"label",
+ "name",
"placeholder",
"options",
"defaultValue",
@@ -130,6 +131,7 @@
{
"name": "Events",
"props": [
+ "onChange",
"onSelect"
]
}
diff --git a/playbook/app/pb_kits/playbook/pb_dropdown/docs/_playground.overrides.json b/playbook/app/pb_kits/playbook/pb_dropdown/docs/_playground.overrides.json
index cc84af3363..b02dcb5c4d 100644
--- a/playbook/app/pb_kits/playbook/pb_dropdown/docs/_playground.overrides.json
+++ b/playbook/app/pb_kits/playbook/pb_dropdown/docs/_playground.overrides.json
@@ -70,6 +70,7 @@
"name": "Content",
"props": [
"label",
+ "name",
"placeholder",
"options",
"defaultValue",
@@ -111,7 +112,7 @@
},
{
"name": "Events",
- "props": ["onSelect"]
+ "props": ["onChange", "onSelect"]
}
],
"presets": [
diff --git a/playbook/app/pb_kits/playbook/pb_dropdown/docs/example.yml b/playbook/app/pb_kits/playbook/pb_dropdown/docs/example.yml
index 42b288f5d4..4ae89b9d1e 100644
--- a/playbook/app/pb_kits/playbook/pb_dropdown/docs/example.yml
+++ b/playbook/app/pb_kits/playbook/pb_dropdown/docs/example.yml
@@ -39,6 +39,9 @@ examples:
- dropdown_disabled: Disabled Input
- dropdown_grouped_options: Grouped Options
- dropdown_custom_event_type: Custom Event Type
+ - dropdown_dynamic_options: Dynamic Options
+ - dropdown_dynamic_options_with_autocomplete: Dynamic Options with Autocomplete
+ - dropdown_dynamic_options_with_multi_select: Dynamic Options with Multi Select
react:
- dropdown_default: Default
@@ -81,3 +84,6 @@ examples:
- dropdown_required_indicator: Required Indicator
- dropdown_disabled: Disabled Input
- dropdown_grouped_options: Grouped Options
+ - dropdown_react_hook: React Hook
+ - dropdown_multi_select_react_hook: React Hook Multi Select
+
diff --git a/playbook/app/pb_kits/playbook/pb_dropdown/docs/index.js b/playbook/app/pb_kits/playbook/pb_dropdown/docs/index.js
index c1e4b9e75a..57eea9f555 100644
--- a/playbook/app/pb_kits/playbook/pb_dropdown/docs/index.js
+++ b/playbook/app/pb_kits/playbook/pb_dropdown/docs/index.js
@@ -1,4 +1,6 @@
export { default as DropdownDefault } from './_dropdown_default.jsx'
+export { default as DropdownReactHook } from './_dropdown_react_hook.jsx'
+export { default as DropdownMultiSelectReactHook } from './_dropdown_multi_select_react_hook.jsx'
export { default as DropdownWithCustomDisplay } from './_dropdown_with_custom_display.jsx'
export { default as DropdownWithCustomOptions } from './_dropdown_with_custom_options.jsx'
export { default as DropdownWithCustomTrigger } from './_dropdown_with_custom_trigger.jsx'
diff --git a/playbook/app/pb_kits/playbook/pb_dropdown/dropdown.rb b/playbook/app/pb_kits/playbook/pb_dropdown/dropdown.rb
index ad58c66c94..3106224e28 100644
--- a/playbook/app/pb_kits/playbook/pb_dropdown/dropdown.rb
+++ b/playbook/app/pb_kits/playbook/pb_dropdown/dropdown.rb
@@ -59,6 +59,14 @@ class Dropdown < Playbook::KitBase
default: false
prop :custom_event_type, type: Playbook::Props::String,
default: ""
+ prop :options_by_context, type: Playbook::Props::HashProp,
+ default: {}
+ prop :context_selector, type: Playbook::Props::String,
+ default: ""
+ prop :clear_on_context_change, type: Playbook::Props::Boolean,
+ default: true
+ prop :options_event_type, type: Playbook::Props::String,
+ default: ""
def data
Hash(prop(:data)).merge(
@@ -73,7 +81,11 @@ def data
end_date_id: variant == "quickpick" ? end_date_id : nil,
controls_start_id: variant == "quickpick" && controls_start_id.present? ? controls_start_id : nil,
controls_end_id: variant == "quickpick" && controls_end_id.present? ? controls_end_id : nil,
- custom_event_type: custom_event_type.presence
+ custom_event_type: custom_event_type.presence,
+ pb_dropdown_options_by_context: options_by_context.present? ? options_by_context.to_json : nil,
+ pb_dropdown_context_selector: context_selector.presence,
+ pb_dropdown_clear_on_context_change: clear_on_context_change,
+ options_event_type: options_event_type.presence
).compact
end
diff --git a/playbook/app/pb_kits/playbook/pb_dropdown/dropdown.test.jsx b/playbook/app/pb_kits/playbook/pb_dropdown/dropdown.test.jsx
index 214e601c3f..e76ac511d7 100644
--- a/playbook/app/pb_kits/playbook/pb_dropdown/dropdown.test.jsx
+++ b/playbook/app/pb_kits/playbook/pb_dropdown/dropdown.test.jsx
@@ -1,5 +1,5 @@
import React, { useState } from "react"
-import { render, screen, fireEvent, waitFor } from "../utilities/test-utils"
+import { render, screen, fireEvent, waitFor, act } from "../utilities/test-utils"
import { Dropdown, Icon, IconCircle } from 'playbook-ui'
import DateTime from "../pb_kit/dateTime.ts"
@@ -995,4 +995,207 @@ test('disabled prop disables autocomplete input', () => {
const input = kit.querySelector('.dropdown_input')
expect(input).toBeDisabled()
-})
\ No newline at end of file
+})
+
+test('onChange uses react-hook-form event shape', () => {
+ const onChange = jest.fn()
+
+ render(
+
+ )
+
+ const kit = screen.getByTestId(testId)
+ fireEvent.click(kit.querySelectorAll('.pb_dropdown_option_list')[0])
+
+ expect(onChange).toHaveBeenCalledWith({
+ target: { name: 'color', value: options[0] },
+ })
+})
+
+test('react-hook-form onChange receives selected options for multiSelect', () => {
+ const onChange = jest.fn()
+
+ render(
+
+ )
+
+ const kit = screen.getByTestId(testId)
+ fireEvent.click(kit.querySelectorAll('.pb_dropdown_option_list')[0])
+ fireEvent.click(kit.querySelectorAll('.pb_dropdown_option_list')[0])
+
+ expect(onChange.mock.calls[0][0]).toEqual({
+ target: { name: 'languages', value: [options[0]] },
+ })
+ expect(onChange.mock.calls[1][0]).toEqual({
+ target: { name: 'languages', value: [options[0], options[1]] },
+ })
+})
+
+test('onSelect still fires when onChange is provided', () => {
+ const onSelect = jest.fn()
+ const onChange = jest.fn()
+
+ render(
+
+ )
+
+ const kit = screen.getByTestId(testId)
+ fireEvent.click(kit.querySelectorAll('.pb_dropdown_option_list')[0])
+
+ expect(onSelect).toHaveBeenCalledWith(options[0])
+ expect(onChange).toHaveBeenCalledWith({
+ target: { name: 'country', value: options[0] },
+ })
+})
+
+test('onSelect-only single select still receives the option object', () => {
+ const onSelect = jest.fn()
+
+ render(
+
+ )
+
+ const kit = screen.getByTestId(testId)
+ fireEvent.click(kit.querySelectorAll('.pb_dropdown_option_list')[0])
+
+ expect(onSelect).toHaveBeenCalledTimes(1)
+ expect(onSelect).toHaveBeenCalledWith(options[0])
+})
+
+test('onSelect-only multiSelect still receives the selected options array', () => {
+ const onSelect = jest.fn()
+
+ render(
+
+ )
+
+ const kit = screen.getByTestId(testId)
+ fireEvent.click(kit.querySelectorAll('.pb_dropdown_option_list')[0])
+ fireEvent.click(kit.querySelectorAll('.pb_dropdown_option_list')[0])
+
+ expect(onSelect.mock.calls[0][0]).toEqual([options[0]])
+ expect(onSelect.mock.calls[1][0]).toEqual([options[0], options[1]])
+})
+
+test('onSelect-only clearSelected still receives null', () => {
+ const onSelect = jest.fn()
+ const dropdownRef = React.createRef()
+
+ render(
+
+ )
+
+ act(() => {
+ dropdownRef.current.clearSelected()
+ })
+
+ expect(onSelect).toHaveBeenCalledWith(null)
+})
+
+test('onSelect-only clear icon still receives null', () => {
+ const onSelect = jest.fn()
+
+ render(
+
+ )
+
+ const kit = screen.getByTestId(testId)
+ fireEvent.click(kit.querySelector('[aria-label="times icon"]').closest('div'))
+
+ expect(onSelect).toHaveBeenCalledWith(null)
+})
+
+test('onSelect-only removing a multiSelect pill still receives remaining options', () => {
+ const onSelect = jest.fn()
+
+ render(
+
+ )
+
+ const kit = screen.getByTestId(testId)
+ fireEvent.click(kit.querySelector('.pb_form_pill_close'))
+
+ expect(onSelect).toHaveBeenCalledWith([options[1]])
+})
+
+test('autocomplete typing does not fire onSelect or onChange', () => {
+ const onSelect = jest.fn()
+ const onChange = jest.fn()
+
+ render(
+
+ )
+
+ const kit = screen.getByTestId(testId)
+ fireEvent.change(kit.querySelector('.dropdown_input'), { target: { value: 'Can' } })
+
+ expect(onSelect).not.toHaveBeenCalled()
+ expect(onChange).not.toHaveBeenCalled()
+})
+
+test('autocomplete selection still fires onSelect', () => {
+ const onSelect = jest.fn()
+
+ render(
+
+ )
+
+ const kit = screen.getByTestId(testId)
+ fireEvent.click(kit.querySelectorAll('.pb_dropdown_option_list')[1])
+
+ expect(onSelect).toHaveBeenCalledWith(options[1])
+})
diff --git a/playbook/app/pb_kits/playbook/pb_dropdown/dropdown_index.test.js b/playbook/app/pb_kits/playbook/pb_dropdown/dropdown_index.test.js
new file mode 100644
index 0000000000..1931dfa29a
--- /dev/null
+++ b/playbook/app/pb_kits/playbook/pb_dropdown/dropdown_index.test.js
@@ -0,0 +1,372 @@
+import PbDropdown from "./index";
+
+const OPTION_SELECTOR = "[data-dropdown-option-label]";
+
+function buildDropdownElement({
+ id = "test-dropdown",
+ options = [
+ { id: "us", label: "United States", value: "us" },
+ { id: "ca", label: "Canada", value: "ca" },
+ ],
+ optionsByContext = null,
+ contextSelector = null,
+ optionsEventType = null,
+} = {}) {
+ const root = document.createElement("div");
+ root.setAttribute("data-pb-dropdown", "true");
+ root.id = id;
+ root.dataset.pbDropdownDisabled = "false";
+ root.dataset.pbDropdownMultiSelect = "false";
+ root.dataset.pbDropdownClearable = "true";
+
+ if (optionsByContext) {
+ root.dataset.pbDropdownOptionsByContext = JSON.stringify(optionsByContext);
+ }
+ if (contextSelector) {
+ root.dataset.pbDropdownContextSelector = contextSelector;
+ }
+ if (optionsEventType) {
+ root.dataset.optionsEventType = optionsEventType;
+ }
+
+ root.innerHTML = `
+
+ `;
+
+ const list = root.querySelector(".pb_list_kit");
+ options.forEach((option) => {
+ const dropdown = new PbDropdown(root);
+ list.appendChild(dropdown.buildOptionElement(option));
+ });
+
+ document.body.appendChild(root);
+ return root;
+}
+
+describe("PbDropdown dynamic options", () => {
+ let dropdownEl;
+ let instance;
+
+ beforeEach(() => {
+ document.body.innerHTML = "";
+ dropdownEl = buildDropdownElement();
+ instance = new PbDropdown(dropdownEl);
+ instance.connect();
+ });
+
+ afterEach(() => {
+ instance.disconnect();
+ document.body.innerHTML = "";
+ });
+
+ test("replaceOptions updates rendered option count", () => {
+ expect(dropdownEl.querySelectorAll(OPTION_SELECTOR).length).toBe(2);
+
+ instance.replaceOptions([
+ { id: "mx", label: "Mexico", value: "mx" },
+ { id: "pk", label: "Pakistan", value: "pk" },
+ { id: "in", label: "India", value: "in" },
+ ]);
+
+ expect(dropdownEl.querySelectorAll(OPTION_SELECTOR).length).toBe(3);
+ });
+
+ test("pb:dropdown:updateOptions replaces options for matching dropdownId", () => {
+ document.dispatchEvent(
+ new CustomEvent("pb:dropdown:updateOptions", {
+ detail: {
+ dropdownId: "test-dropdown",
+ options: [{ id: "uk", label: "United Kingdom", value: "uk" }],
+ },
+ }),
+ );
+
+ const options = dropdownEl.querySelectorAll(OPTION_SELECTOR);
+ expect(options.length).toBe(1);
+ expect(JSON.parse(options[0].dataset.dropdownOptionLabel).label).toBe(
+ "United Kingdom",
+ );
+ });
+
+ test("pb:dropdown:updateOptions ignores events for other dropdown ids", () => {
+ document.dispatchEvent(
+ new CustomEvent("pb:dropdown:updateOptions", {
+ detail: {
+ dropdownId: "other-dropdown",
+ options: [{ id: "uk", label: "United Kingdom", value: "uk" }],
+ },
+ }),
+ );
+
+ expect(dropdownEl.querySelectorAll(OPTION_SELECTOR).length).toBe(2);
+ });
+
+ test("options_event_type listener replaces options from custom events", () => {
+ instance.disconnect();
+ dropdownEl = buildDropdownElement({ optionsEventType: "cities:loaded" });
+ instance = new PbDropdown(dropdownEl);
+ instance.connect();
+
+ document.dispatchEvent(
+ new CustomEvent("cities:loaded", {
+ detail: {
+ dropdownId: "test-dropdown",
+ options: [{ id: "chi", label: "Chicago", value: "chi" }],
+ },
+ }),
+ );
+
+ expect(dropdownEl.querySelectorAll(OPTION_SELECTOR).length).toBe(1);
+ });
+
+ test("options_by_context updates options when context select changes", () => {
+ instance.disconnect();
+ document.body.innerHTML = "";
+
+ const contextSelect = document.createElement("select");
+ contextSelect.id = "color_context";
+ contextSelect.innerHTML = `
+
+
+ `;
+ document.body.appendChild(contextSelect);
+
+ dropdownEl = buildDropdownElement({
+ optionsByContext: {
+ red: [{ id: "scarlet", label: "Scarlet", value: "scarlet" }],
+ blue: [{ id: "navy", label: "Navy", value: "navy" }],
+ },
+ contextSelector: "color_context",
+ options: [{ id: "scarlet", label: "Scarlet", value: "scarlet" }],
+ });
+ instance = new PbDropdown(dropdownEl);
+ instance.connect();
+
+ contextSelect.value = "blue";
+ contextSelect.dispatchEvent(new Event("change"));
+
+ const options = dropdownEl.querySelectorAll(OPTION_SELECTOR);
+ expect(options.length).toBe(1);
+ expect(JSON.parse(options[0].dataset.dropdownOptionLabel).label).toBe(
+ "Navy",
+ );
+ });
+
+ test("options_by_context applies current context value on connect", () => {
+ instance.disconnect();
+ document.body.innerHTML = "";
+
+ const contextSelect = document.createElement("select");
+ contextSelect.id = "color_context_initial";
+ contextSelect.innerHTML = `
+
+
+ `;
+ document.body.appendChild(contextSelect);
+
+ dropdownEl = buildDropdownElement({
+ optionsByContext: {
+ red: [{ id: "scarlet", label: "Scarlet", value: "scarlet" }],
+ blue: [{ id: "navy", label: "Navy", value: "navy" }],
+ },
+ contextSelector: "color_context_initial",
+ // Intentionally mismatched SSR options (red shades) while select is blue
+ options: [{ id: "scarlet", label: "Scarlet", value: "scarlet" }],
+ });
+ instance = new PbDropdown(dropdownEl);
+ instance.connect();
+
+ const options = dropdownEl.querySelectorAll(OPTION_SELECTOR);
+ expect(options.length).toBe(1);
+ expect(JSON.parse(options[0].dataset.dropdownOptionLabel).label).toBe(
+ "Navy",
+ );
+ });
+
+ test("pb:dropdown:clear and pb:dropdown:select still work after option update", () => {
+ document.dispatchEvent(
+ new CustomEvent("pb:dropdown:updateOptions", {
+ detail: {
+ dropdownId: "test-dropdown",
+ options: [
+ { id: "us", label: "United States", value: "us" },
+ { id: "ca", label: "Canada", value: "ca" },
+ ],
+ },
+ }),
+ );
+
+ document.dispatchEvent(
+ new CustomEvent("pb:dropdown:select", {
+ detail: { dropdownId: "test-dropdown", optionId: "ca" },
+ }),
+ );
+
+ expect(dropdownEl.querySelector("input[data-dropdown-selected-option]").value).toBe(
+ "ca",
+ );
+
+ document.dispatchEvent(
+ new CustomEvent("pb:dropdown:clear", {
+ detail: { dropdownId: "test-dropdown" },
+ }),
+ );
+
+ expect(dropdownEl.querySelector("input[data-dropdown-selected-option]").value).toBe(
+ "",
+ );
+ });
+
+ test("replaceOptions with clearSelection false refreshes single-select from new payload", () => {
+ instance.setSelectionByOptionId("us");
+ expect(
+ dropdownEl.querySelector("[data-dropdown-trigger-display]").textContent,
+ ).toBe("United States");
+
+ instance.replaceOptions(
+ [
+ { id: "us", label: "USA", value: "united-states" },
+ { id: "ca", label: "Canada", value: "ca" },
+ ],
+ { clearSelection: false },
+ );
+
+ expect(dropdownEl.querySelector("input[data-dropdown-selected-option]").value).toBe(
+ "us",
+ );
+ expect(
+ dropdownEl.querySelector("[data-dropdown-trigger-display]").textContent,
+ ).toBe("USA");
+ });
+
+ test("replaceOptions with clearSelection false refreshes multi-select payloads", () => {
+ instance.disconnect();
+ dropdownEl = buildDropdownElement({
+ options: [
+ { id: "us", label: "United States", value: "us" },
+ { id: "ca", label: "Canada", value: "ca" },
+ ],
+ });
+ dropdownEl.dataset.pbDropdownMultiSelect = "true";
+ dropdownEl.innerHTML = `
+
+ `;
+ const list = dropdownEl.querySelector(".pb_list_kit");
+ [
+ { id: "us", label: "United States", value: "us" },
+ { id: "ca", label: "Canada", value: "ca" },
+ ].forEach((option) => {
+ list.appendChild(new PbDropdown(dropdownEl).buildOptionElement(option));
+ });
+ instance = new PbDropdown(dropdownEl);
+ instance.connect();
+ instance.setSelectionByOptionIds(["us"]);
+
+ instance.replaceOptions(
+ [
+ { id: "us", label: "USA", value: "united-states" },
+ { id: "ca", label: "Canada", value: "ca" },
+ ],
+ { clearSelection: false },
+ );
+
+ const selectedPayload = Array.from(instance.selectedOptions).map(JSON.parse);
+ expect(selectedPayload).toEqual([
+ { id: "us", label: "USA", value: "united-states" },
+ ]);
+ expect(
+ dropdownEl.querySelector("[data-pill-id='us'] .pb_form_pill_text").textContent,
+ ).toBe("USA");
+ });
+
+ test("replaceOptions clears autocomplete filter and keyboard focus", () => {
+ instance.disconnect();
+ dropdownEl = buildDropdownElement();
+ const trigger = dropdownEl.querySelector(".pb_dropdown_trigger");
+ trigger.innerHTML = `
+
+ Choose one
+ `;
+ instance = new PbDropdown(dropdownEl);
+ instance.connect();
+
+ instance.searchInput.value = "can";
+ instance.handleSearch("can");
+ instance.keyboardHandler.focusedOptionIndex = 1;
+
+ instance.replaceOptions([
+ { id: "mx", label: "Mexico", value: "mx" },
+ { id: "pk", label: "Pakistan", value: "pk" },
+ ]);
+
+ expect(instance.searchInput.value).toBe("");
+ expect(instance.keyboardHandler.focusedOptionIndex).toBe(-1);
+ const options = dropdownEl.querySelectorAll(OPTION_SELECTOR);
+ expect(options.length).toBe(2);
+ options.forEach((opt) => {
+ expect(opt.style.display).toBe("");
+ });
+ });
+
+ test("replaceOptions with clearSelection false restores autocomplete from new label", () => {
+ instance.disconnect();
+ dropdownEl = buildDropdownElement();
+ const trigger = dropdownEl.querySelector(".pb_dropdown_trigger");
+ trigger.innerHTML = `
+
+ Choose one
+ `;
+ instance = new PbDropdown(dropdownEl);
+ instance.connect();
+ instance.setSelectionByOptionId("us");
+ instance.searchInput.value = "united";
+
+ instance.replaceOptions(
+ [
+ { id: "us", label: "USA", value: "united-states" },
+ { id: "ca", label: "Canada", value: "ca" },
+ ],
+ { clearSelection: false },
+ );
+
+ expect(instance.searchInput.value).toBe("USA");
+ expect(instance.keyboardHandler.focusedOptionIndex).toBe(-1);
+ });
+
+ test("replaceOptions removes SSR empty-state No option placeholder", () => {
+ instance.disconnect();
+ dropdownEl = buildDropdownElement({ options: [] });
+ const list = dropdownEl.querySelector(".pb_list_kit");
+ list.innerHTML = `
+
+ `;
+ instance = new PbDropdown(dropdownEl);
+ instance.connect();
+
+ instance.replaceOptions([
+ { id: "scarlet", label: "Scarlet", value: "scarlet" },
+ ]);
+
+ expect(list.textContent).not.toContain("No option");
+ expect(dropdownEl.querySelectorAll(OPTION_SELECTOR).length).toBe(1);
+ });
+});
diff --git a/playbook/app/pb_kits/playbook/pb_dropdown/index.js b/playbook/app/pb_kits/playbook/pb_dropdown/index.js
index b81b779dda..e2f50b99cd 100644
--- a/playbook/app/pb_kits/playbook/pb_dropdown/index.js
+++ b/playbook/app/pb_kits/playbook/pb_dropdown/index.js
@@ -162,6 +162,8 @@ export default class PbDropdown extends PbEnhancedElement {
const baseInput = this.baseInput;
this.wasOriginallyRequired =
baseInput && baseInput.hasAttribute("required");
+ // Apply context options before default value so SSR/default selection matches current context
+ this.bindContextSelector();
this.setDefaultValue();
this.bindEventListeners();
this.bindSearchInput();
@@ -184,11 +186,13 @@ export default class PbDropdown extends PbEnhancedElement {
this.updateClearButton();
this.applyDisabledState();
- // Listen for clear and select events from external source
+ // Listen for clear, select, and updateOptions events from external source
this.handleClearEventBound = this.handleClearEvent.bind(this);
document.addEventListener("pb:dropdown:clear", this.handleClearEventBound);
this.handleSelectEventBound = this.handleSelectEvent.bind(this);
document.addEventListener("pb:dropdown:select", this.handleSelectEventBound);
+ this.handleUpdateOptionsEventBound = this.handleUpdateOptionsEvent.bind(this);
+ document.addEventListener("pb:dropdown:updateOptions", this.handleUpdateOptionsEventBound);
// Listen for custom_event_type to clear on custom events
const customEventTypeString = this.element.dataset.customEventType;
@@ -202,6 +206,19 @@ export default class PbDropdown extends PbEnhancedElement {
document.addEventListener(eventType, this.handleCustomClearBound);
});
}
+
+ // Listen for options_event_type to replace options on custom/Turbo events
+ const optionsEventTypeString = this.element.dataset.optionsEventType;
+ if (optionsEventTypeString) {
+ this.optionsEventTypes = optionsEventTypeString
+ .split(",")
+ .map((e) => e.trim())
+ .filter(Boolean);
+ this.handleOptionsEventTypeBound = this.handleOptionsEventType.bind(this);
+ this.optionsEventTypes.forEach((eventType) => {
+ document.addEventListener(eventType, this.handleOptionsEventTypeBound);
+ });
+ }
}
disconnect() {
@@ -263,11 +280,22 @@ export default class PbDropdown extends PbEnhancedElement {
if (this.handleSelectEventBound) {
document.removeEventListener("pb:dropdown:select", this.handleSelectEventBound)
}
+ if (this.handleUpdateOptionsEventBound) {
+ document.removeEventListener("pb:dropdown:updateOptions", this.handleUpdateOptionsEventBound)
+ }
if (this.customClearEventTypes && this.handleCustomClearBound) {
this.customClearEventTypes.forEach((eventType) => {
document.removeEventListener(eventType, this.handleCustomClearBound)
})
}
+ if (this.optionsEventTypes && this.handleOptionsEventTypeBound) {
+ this.optionsEventTypes.forEach((eventType) => {
+ document.removeEventListener(eventType, this.handleOptionsEventTypeBound)
+ })
+ }
+ if (this.contextElement && this.handleContextChangeBound) {
+ this.contextElement.removeEventListener("change", this.handleContextChangeBound)
+ }
}
updateClearButton() {
@@ -630,6 +658,217 @@ export default class PbDropdown extends PbEnhancedElement {
}
// ----- External events handling section -----
+
+ get optionsByContext() {
+ return this.element.dataset.pbDropdownOptionsByContext
+ ? JSON.parse(this.element.dataset.pbDropdownOptionsByContext)
+ : null;
+ }
+
+ get contextElement() {
+ const selector = this.element.dataset.pbDropdownContextSelector;
+ if (!selector) return null;
+
+ return (
+ this.element.parentNode?.querySelector(`#${CSS.escape(selector)}`) ||
+ this.element.closest(`#${CSS.escape(selector)}`) ||
+ document.getElementById(selector)
+ );
+ }
+
+ get clearOnContextChange() {
+ return this.element.dataset.pbDropdownClearOnContextChange !== "false";
+ }
+
+ bindContextSelector() {
+ const contextEl = this.contextElement;
+ if (!contextEl || !this.optionsByContext) return;
+
+ this.handleContextChangeBound = this.handleContextChange.bind(this);
+ contextEl.addEventListener("change", this.handleContextChangeBound);
+
+ // Sync to the current context value on connect (default/restored select), without clearing
+ this.applyOptionsForCurrentContext({ clearSelection: false });
+ }
+
+ applyOptionsForCurrentContext({ clearSelection } = {}) {
+ if (this.isDisabled || !this.optionsByContext) return;
+
+ const options = this.optionsByContext[this.contextElement?.value] || [];
+ this.replaceOptions(options, {
+ clearSelection:
+ clearSelection != null ? clearSelection : this.clearOnContextChange,
+ });
+ }
+
+ handleContextChange() {
+ this.applyOptionsForCurrentContext();
+ }
+
+ normalizeOption(option) {
+ const normalized = { ...option };
+ if (normalized.id == null && normalized.value != null) {
+ normalized.id = normalized.value;
+ }
+ if (normalized.value == null && normalized.id != null) {
+ normalized.value = normalized.id;
+ }
+ return normalized;
+ }
+
+ getOptionsParent() {
+ const container = this.target;
+ if (!container) return null;
+
+ return container.querySelector(".pb_list_kit") || container;
+ }
+
+ buildOptionElement(option) {
+ const normalized = this.normalizeOption(option);
+ const disabled = normalized.disabled === true;
+ const optionEl = document.createElement("div");
+ optionEl.className = `pb_dropdown_option_list${disabled ? " disabled" : ""}`;
+ if (normalized.id != null && normalized.id !== "") {
+ optionEl.id = String(normalized.id);
+ }
+ optionEl.setAttribute("aria-disabled", disabled ? "true" : "false");
+ optionEl.dataset.dropdownOptionLabel = JSON.stringify(normalized);
+ optionEl.dataset.dropdownOptionDisabled = disabled ? "true" : "false";
+
+ const listItem = document.createElement("div");
+ listItem.className =
+ "pb_list_item_kit display_flex justify_content_center p_none cursor_pointer";
+
+ const wrapper = document.createElement("div");
+ wrapper.className = disabled
+ ? "dropdown_option_wrapper disabled"
+ : "dropdown_option_wrapper";
+
+ const body = document.createElement("div");
+ body.className = "pb_body_kit_light";
+ body.textContent =
+ normalized.label != null ? String(normalized.label) : "";
+
+ wrapper.appendChild(body);
+ listItem.appendChild(wrapper);
+ optionEl.appendChild(listItem);
+
+ return optionEl;
+ }
+
+ replaceOptions(options, { clearSelection = true } = {}) {
+ if (this.isDisabled || !Array.isArray(options)) return;
+
+ const parent = this.getOptionsParent();
+ if (!parent) return;
+
+ // Clear option nodes and any SSR empty-state ("No option") placeholder
+ parent.replaceChildren();
+ this.removeNoOptionsMessage();
+
+ options.forEach((option) => {
+ parent.appendChild(this.buildOptionElement(option));
+ });
+
+ // Clear typed filters and keyboard focus so new options aren't left unfiltered /
+ // focused against stale indexes from the previous list
+ this.resetInteractiveOptionState();
+
+ if (clearSelection) {
+ this.clearSelection();
+ } else {
+ this.reconcileSelectionWithOptions();
+ }
+
+ if (this.target?.classList.contains("open")) {
+ this.adjustDropdownHeight();
+ }
+ }
+
+ resetInteractiveOptionState() {
+ this.resetFocus();
+
+ if (this.searchBar) {
+ this.searchBar.value = "";
+ }
+
+ if (this.searchInput) {
+ this.searchInput.value = "";
+ }
+ }
+
+ reconcileSelectionWithOptions() {
+ const optionEls = Array.from(this.queryAllOptions());
+ const optionsById = new Map();
+ optionEls.forEach((opt) => {
+ try {
+ const optionData = JSON.parse(opt.dataset.dropdownOptionLabel);
+ if (optionData?.id != null) {
+ optionsById.set(optionData.id, opt);
+ }
+ } catch {
+ // ignore invalid option payloads
+ }
+ });
+
+ if (this.isMultiSelect) {
+ const keptIds = Array.from(this.selectedOptions)
+ .map((raw) => {
+ try {
+ return JSON.parse(raw).id;
+ } catch {
+ return null;
+ }
+ })
+ .filter((id) => id != null && optionsById.has(id));
+
+ if (keptIds.length === 0) {
+ this.clearSelection();
+ return;
+ }
+
+ // Rebuild from current option payloads so labels/values stay in sync
+ this.setSelectionByOptionIds(keptIds);
+ return;
+ }
+
+ const currentId = this.baseInput?.value;
+ if (!currentId || !optionsById.has(currentId)) {
+ this.clearSelection();
+ return;
+ }
+
+ // Re-apply selection so trigger/autocomplete use the updated option payload
+ this.setSelectionByOptionId(currentId);
+ }
+
+ // Handles pb:dropdown:updateOptions - replace options when event.detail.dropdownId matches.
+ // detail: { dropdownId, options: [{ id, label, value }], clearSelection?: boolean }
+ handleUpdateOptionsEvent(event) {
+ if (this.isDisabled) return;
+ const targetId = event.detail?.dropdownId;
+ if (!targetId || this.element.id !== targetId) return;
+
+ const options = event.detail?.options;
+ if (!Array.isArray(options)) return;
+
+ const clearSelection = event.detail?.clearSelection !== false;
+ this.replaceOptions(options, { clearSelection });
+ }
+
+ // Handles options_event_type events - replace options when detail.options is present.
+ handleOptionsEventType(event) {
+ if (this.isDisabled) return;
+ const targetId = event.detail?.dropdownId;
+ if (targetId != null && this.element.id !== targetId) return;
+
+ const options = event.detail?.options;
+ if (!Array.isArray(options)) return;
+
+ const clearSelection = event.detail?.clearSelection !== false;
+ this.replaceOptions(options, { clearSelection });
+ }
+
// Handles pb:dropdown:clear - clear this dropdown when event.detail.dropdownId matches.
handleClearEvent(event) {
if (this.isDisabled) return;
diff --git a/playbook/app/pb_kits/playbook/pb_dropdown/kit.schema.json b/playbook/app/pb_kits/playbook/pb_dropdown/kit.schema.json
index 04f25ad78f..059e2f0f81 100644
--- a/playbook/app/pb_kits/playbook/pb_dropdown/kit.schema.json
+++ b/playbook/app/pb_kits/playbook/pb_dropdown/kit.schema.json
@@ -115,6 +115,19 @@
],
"default": false
},
+ "name": {
+ "type": "string",
+ "platforms": [
+ "react",
+ "rails"
+ ]
+ },
+ "onChange": {
+ "type": "function",
+ "platforms": [
+ "react"
+ ]
+ },
"onSelect": {
"type": "function",
"platforms": [
@@ -194,12 +207,6 @@
],
"default": false
},
- "name": {
- "platforms": [
- "rails"
- ],
- "type": "string"
- },
"required": {
"platforms": [
"rails"
@@ -248,6 +255,33 @@
],
"type": "string",
"default": ""
+ },
+ "optionsByContext": {
+ "platforms": [
+ "rails"
+ ],
+ "type": "GenericObject"
+ },
+ "contextSelector": {
+ "platforms": [
+ "rails"
+ ],
+ "type": "string",
+ "default": ""
+ },
+ "clearOnContextChange": {
+ "platforms": [
+ "rails"
+ ],
+ "type": "boolean",
+ "default": true
+ },
+ "optionsEventType": {
+ "platforms": [
+ "rails"
+ ],
+ "type": "string",
+ "default": ""
}
},
"globalProps": true,
diff --git a/playbook/app/pb_kits/playbook/pb_dropdown/subcomponents/MultiSelectTriggerDisplay.tsx b/playbook/app/pb_kits/playbook/pb_dropdown/subcomponents/MultiSelectTriggerDisplay.tsx
index 12e58eb560..e3fd33a322 100644
--- a/playbook/app/pb_kits/playbook/pb_dropdown/subcomponents/MultiSelectTriggerDisplay.tsx
+++ b/playbook/app/pb_kits/playbook/pb_dropdown/subcomponents/MultiSelectTriggerDisplay.tsx
@@ -19,7 +19,7 @@ const MultiSelectTriggerDisplay = ({
dark = false,
}: MultiSelectTriggerDisplayProps) => {
- const { setSelected, onSelect, formPillProps } = useContext(DropdownContext);
+ const { setSelected, handleSelectionChange, formPillProps } = useContext(DropdownContext);
if (selected.length === 0) {
if (autocomplete) return null;
@@ -35,7 +35,7 @@ const MultiSelectTriggerDisplay = ({
const handleRemoveIconClick = (option: GenericObject) => {
setSelected((prev: GenericObject[]) => {
const next = prev.filter((item) => item.label !== option.label);
- onSelect && onSelect(next);
+ handleSelectionChange && handleSelectionChange(next);
return next;
});
}
diff --git a/playbook/spec/pb_kits/playbook/kits/dropdown_spec.rb b/playbook/spec/pb_kits/playbook/kits/dropdown_spec.rb
index 23518b9f9d..cfb9fec3df 100644
--- a/playbook/spec/pb_kits/playbook/kits/dropdown_spec.rb
+++ b/playbook/spec/pb_kits/playbook/kits/dropdown_spec.rb
@@ -32,6 +32,10 @@
it { is_expected.to define_boolean_prop(:constrain_height).with_default(false) }
it { is_expected.to define_boolean_prop(:required_indicator).with_default(false) }
it { is_expected.to define_string_prop(:custom_event_type).with_default("") }
+ it { is_expected.to define_hash_prop(:options_by_context).with_default({}) }
+ it { is_expected.to define_string_prop(:context_selector).with_default("") }
+ it { is_expected.to define_boolean_prop(:clear_on_context_change).with_default(true) }
+ it { is_expected.to define_string_prop(:options_event_type).with_default("") }
describe "#classname" do
it "returns namespaced class name", :aggregate_failures do
@@ -468,6 +472,47 @@
end
end
+ describe "dynamic options props" do
+ let(:options_by_context) do
+ {
+ "red" => [{ id: "scarlet", label: "Scarlet", value: "scarlet" }],
+ "blue" => [{ id: "navy", label: "Navy", value: "navy" }],
+ }
+ end
+
+ it "includes options_by_context in data when present" do
+ dropdown = subject.new(options_by_context: options_by_context)
+ expect(dropdown.data).to include(
+ pb_dropdown_options_by_context: options_by_context.to_json
+ )
+ end
+
+ it "omits options_by_context from data when empty" do
+ dropdown = subject.new(options_by_context: {})
+ expect(dropdown.data).not_to have_key(:pb_dropdown_options_by_context)
+ end
+
+ it "includes context_selector in data when present" do
+ dropdown = subject.new(context_selector: "color_context")
+ expect(dropdown.data).to include(pb_dropdown_context_selector: "color_context")
+ end
+
+ it "includes clear_on_context_change in data" do
+ dropdown = subject.new(clear_on_context_change: false)
+ expect(dropdown.data).to include(pb_dropdown_clear_on_context_change: false)
+ end
+
+ it "includes options_event_type in data when present" do
+ dropdown = subject.new(options_event_type: "turbo:frame-load,cities:loaded")
+ expect(dropdown.data).to include(options_event_type: "turbo:frame-load,cities:loaded")
+ end
+
+ it "omits options_event_type from data when blank" do
+ dropdown = subject.new(options_event_type: "")
+ expect(dropdown.data).not_to have_key(:options_event_type)
+ end
+ end
+
describe "#disabled" do
it "includes disabled state in data when true" do
dropdown = subject.new(disabled: true)