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 => ( + <p key={country.id}>{`${country.label} - ${country.value}`}</p> + ))} + </> + ) +} + +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 ( + <> + <Dropdown + label="Countries" + options={options} + {...props} + {...register('country')} + /> + <Title + marginTop="sm" + size={4} + text="Selected Country" + /> + <p>{selectedCountry && `${selectedCountry.label} - ${selectedCountry.value}`}</p> + </> + ) +} + +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( + <Dropdown + data={{ testid: testId }} + name="color" + onChange={onChange} + options={options} + /> + ) + + 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( + <Dropdown + data={{ testid: testId }} + multiSelect + name="languages" + onChange={onChange} + options={options} + /> + ) + + 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( + <Dropdown + data={{ testid: testId }} + name="country" + onChange={onChange} + onSelect={onSelect} + options={options} + /> + ) + + 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( + <Dropdown + data={{ testid: testId }} + onSelect={onSelect} + options={options} + /> + ) + + 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( + <Dropdown + data={{ testid: testId }} + multiSelect + onSelect={onSelect} + options={options} + /> + ) + + 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( + <Dropdown + data={{ testid: testId }} + defaultValue={options[0]} + onSelect={onSelect} + options={options} + ref={dropdownRef} + /> + ) + + act(() => { + dropdownRef.current.clearSelected() + }) + + expect(onSelect).toHaveBeenCalledWith(null) +}) + +test('onSelect-only clear icon still receives null', () => { + const onSelect = jest.fn() + + render( + <Dropdown + data={{ testid: testId }} + defaultValue={options[0]} + onSelect={onSelect} + options={options} + /> + ) + + 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( + <Dropdown + data={{ testid: testId }} + defaultValue={[options[0], options[1]]} + multiSelect + onSelect={onSelect} + options={options} + /> + ) + + 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( + <Dropdown + autocomplete + data={{ testid: testId }} + onChange={onChange} + onSelect={onSelect} + options={options} + /> + ) + + 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( + <Dropdown + autocomplete + data={{ testid: testId }} + onSelect={onSelect} + options={options} + /> + ) + + 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 = ` + <div class="dropdown_wrapper"> + <input data-dropdown-selected-option name="country" style="display: none" /> + <div class="pb_dropdown_trigger"> + <span data-dropdown-trigger-display data-dropdown-placeholder="Choose one">Choose one</span> + </div> + <div class="pb_dropdown_container close" data-dropdown-container="true"> + <div class="pb_list_kit"></div> + </div> + </div> + `; + + 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 = ` + <option value="red">Red</option> + <option value="blue">Blue</option> + `; + 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 = ` + <option value="red">Red</option> + <option value="blue" selected>Blue</option> + `; + 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 = ` + <div class="dropdown_wrapper"> + <input data-dropdown-selected-option name="country[]" style="display: none" /> + <div class="pb_dropdown_trigger"> + <div data-dropdown-pills-wrapper></div> + <span data-dropdown-trigger-display-multi-select>Choose one</span> + </div> + <div class="pb_dropdown_container close" data-dropdown-container="true"> + <div class="pb_list_kit"></div> + </div> + </div> + `; + 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 = ` + <input data-dropdown-autocomplete type="text" /> + <span data-dropdown-trigger-display data-dropdown-placeholder="Choose one">Choose one</span> + `; + 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 = ` + <input data-dropdown-autocomplete type="text" /> + <span data-dropdown-trigger-display data-dropdown-placeholder="Choose one">Choose one</span> + `; + 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 = ` + <div class="pb_list_item_kit display_flex justify_content_center p_xs"> + <div class="pb_body_kit">No option</div> + </div> + `; + 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)