diff --git a/playbook/app/pb_kits/playbook/pb_dropdown/_dropdown.scss b/playbook/app/pb_kits/playbook/pb_dropdown/_dropdown.scss index 95ec999d54..a64e52f2aa 100644 --- a/playbook/app/pb_kits/playbook/pb_dropdown/_dropdown.scss +++ b/playbook/app/pb_kits/playbook/pb_dropdown/_dropdown.scss @@ -96,6 +96,7 @@ width: 100%; } } + .close { display: none; animation-name: fadeOut; @@ -111,6 +112,17 @@ animation-timing-function: linear; animation-fill-mode: forwards; } + + &.error { + [class*=pb_body_kit] { + margin-top: $space_xs / 2; + } + + [class*="dropdown_trigger_wrapper"] { + border-color: $error; + box-shadow: none !important; + } + } } &.dark { @@ -138,6 +150,12 @@ color: $white; } } + + &.error { + [class*="dropdown_trigger_wrapper"] { + border-color: $error_dark; + } + } } .pb_dropdown_container { background-color: $bg_dark !important; diff --git a/playbook/app/pb_kits/playbook/pb_dropdown/_dropdown.tsx b/playbook/app/pb_kits/playbook/pb_dropdown/_dropdown.tsx index 1e51976fbb..99b1347bc3 100644 --- a/playbook/app/pb_kits/playbook/pb_dropdown/_dropdown.tsx +++ b/playbook/app/pb_kits/playbook/pb_dropdown/_dropdown.tsx @@ -4,6 +4,7 @@ import { buildAriaProps, buildCss, buildDataProps, buildHtmlProps } from "../uti import { globalProps } from "../utilities/globalProps"; import { GenericObject } from "../types"; +import Body from '../pb_body/_body'; import Caption from "../pb_caption/_caption"; import DropdownContainer from "./subcomponents/DropdownContainer"; @@ -13,236 +14,245 @@ import DropdownTrigger from "./subcomponents/DropdownTrigger"; import useDropdown from "./hooks/useDropdown"; import { - separateChildComponents, - prepareSubcomponents, - handleClickOutside, + separateChildComponents, + prepareSubcomponents, + handleClickOutside, } from "./utilities"; type DropdownProps = { - aria?: { [key: string]: string }; - autocomplete?: boolean; - children?: React.ReactChild[] | React.ReactChild | React.ReactElement[]; - className?: string; - dark?: boolean; - data?: { [key: string]: string }; - htmlOptions?: {[key: string]: string | number | boolean | (() => void)}, - id?: string; - isClosed?: boolean; - label?: string; - onSelect?: (arg: GenericObject) => null; - options: GenericObject; - triggerRef?: any; + aria?: { [key: string]: string }; + autocomplete?: boolean; + children?: React.ReactChild[] | React.ReactChild | React.ReactElement[]; + className?: string; + dark?: boolean; + data?: { [key: string]: string }; + error?: string; + htmlOptions?: { [key: string]: string | number | boolean | (() => void) }, + id?: string; + isClosed?: boolean; + label?: string; + onSelect?: (arg: GenericObject) => null; + options: GenericObject; + triggerRef?: any; }; const Dropdown = (props: DropdownProps) => { - const { - aria = {}, - autocomplete = false, - children, - className, - dark = false, - data = {}, - htmlOptions = {}, - id, - isClosed = true, - label, - onSelect, - options, - triggerRef - } = props; - - const ariaProps = buildAriaProps(aria); - const dataProps = buildDataProps(data); - const htmlProps = buildHtmlProps(htmlOptions); - const classes = classnames( - buildCss("pb_dropdown"), - globalProps(props), - className - ); - - const [isDropDownClosed, setIsDropDownClosed, toggleDropdown] = useDropdown(isClosed); - - const [filterItem, setFilterItem] = useState(""); - const [selected, setSelected] = useState({}); - const [isInputFocused, setIsInputFocused] = useState(false); - const [hasTriggerSubcomponent, setHasTriggerSubcomponent] = useState(true); - const [hasContainerSubcomponent, setHasContainerSubcomponent] = - useState(true); - //state for keyboard events - const [focusedOptionIndex, setFocusedOptionIndex] = useState(-1); - - const dropdownRef = useRef(null); - const inputRef = useRef(null); - const inputWrapperRef = useRef(null); - const dropdownContainerRef = useRef(null); - - const { trigger, container, otherChildren } = - separateChildComponents(children); - - useEffect(() => { - // Set the parent element of the trigger to relative to allow for absolute positioning of the dropdown - //Only needed for when useDropdown hook used with external trigger - if (triggerRef?.current) { - const parentElement = triggerRef.current.parentNode; - if (parentElement) { - parentElement.style.position = 'relative'; - } - } - // Handle clicks outside the dropdown - const handleClick = handleClickOutside({ - inputWrapperRef, - dropdownContainerRef, - setIsDropDownClosed, - setFocusedOptionIndex, - setIsInputFocused, - }); - - window.addEventListener("click", handleClick); - return () => { - window.removeEventListener("click", handleClick); - }; - }, []); - - useEffect(() => { - setHasTriggerSubcomponent(!!trigger); - setHasContainerSubcomponent(!!container); - }, []); - -// dropdown to toggle with external control - useEffect(()=> { - setIsDropDownClosed(isClosed) - },[isClosed]) - - const filteredOptions = options?.filter((option: GenericObject) => { - const label = typeof option.label === 'string' ? option.label.toLowerCase() : option.label; - return String(label).toLowerCase().includes(filterItem.toLowerCase()); - } - ); - -// For keyboard accessibility: Set focus within dropdown to selected item if it exists - useEffect(() => { - if (!isDropDownClosed) { - let newIndex = 0; - if (selected && selected?.label) { - const selectedIndex = filteredOptions.findIndex((option: GenericObject) => option.label === selected.label); - if (selectedIndex >= 0) { - newIndex = selectedIndex; + const { + aria = {}, + autocomplete = false, + children, + className, + dark = false, + data = {}, + error, + htmlOptions = {}, + id, + isClosed = true, + label, + onSelect, + options, + triggerRef + } = props; + + const ariaProps = buildAriaProps(aria); + const dataProps = buildDataProps(data); + const htmlProps = buildHtmlProps(htmlOptions); + const classes = classnames( + buildCss("pb_dropdown"), + globalProps(props), + className + ); + + const [isDropDownClosed, setIsDropDownClosed, toggleDropdown] = useDropdown(isClosed); + + const [filterItem, setFilterItem] = useState(""); + const [selected, setSelected] = useState({}); + const [isInputFocused, setIsInputFocused] = useState(false); + const [hasTriggerSubcomponent, setHasTriggerSubcomponent] = useState(true); + const [hasContainerSubcomponent, setHasContainerSubcomponent] = + useState(true); + //state for keyboard events + const [focusedOptionIndex, setFocusedOptionIndex] = useState(-1); + + const dropdownRef = useRef(null); + const inputRef = useRef(null); + const inputWrapperRef = useRef(null); + const dropdownContainerRef = useRef(null); + + const { trigger, container, otherChildren } = + separateChildComponents(children); + + useEffect(() => { + // Set the parent element of the trigger to relative to allow for absolute positioning of the dropdown + //Only needed for when useDropdown hook used with external trigger + if (triggerRef?.current) { + const parentElement = triggerRef.current.parentNode; + if (parentElement) { + parentElement.style.position = 'relative'; } } - setFocusedOptionIndex(newIndex); + // Handle clicks outside the dropdown + const handleClick = handleClickOutside({ + inputWrapperRef, + dropdownContainerRef, + setIsDropDownClosed, + setFocusedOptionIndex, + setIsInputFocused, + }); + + window.addEventListener("click", handleClick); + return () => { + window.removeEventListener("click", handleClick); + }; + }, []); + + useEffect(() => { + setHasTriggerSubcomponent(!!trigger); + setHasContainerSubcomponent(!!container); + }, []); + + // dropdown to toggle with external control + useEffect(() => { + setIsDropDownClosed(isClosed) + }, [isClosed]) + + const filteredOptions = options?.filter((option: GenericObject) => { + const label = typeof option.label === 'string' ? option.label.toLowerCase() : option.label; + return String(label).toLowerCase().includes(filterItem.toLowerCase()); } -}, [isDropDownClosed]); - - - const handleChange = (e: React.ChangeEvent) => { - setFilterItem(e.target.value); - setIsDropDownClosed(false); - }; - - const handleOptionClick = (selectedItem: GenericObject) => { - setSelected(selectedItem); - setFilterItem(""); - setIsDropDownClosed(true); - onSelect && onSelect(selectedItem); - }; - - const handleWrapperClick = () => { - autocomplete && inputRef.current.focus(); - toggleDropdown(); - }; - - const handleBackspace = () => { - setSelected({}); - onSelect && onSelect(null); - setFocusedOptionIndex(-1); - }; - - const componentsToRender = prepareSubcomponents({ - children, - hasTriggerSubcomponent, - hasContainerSubcomponent, - trigger, - container, - otherChildren, - dark - }); - - - return ( -
- - {label && - + ); + + // For keyboard accessibility: Set focus within dropdown to selected item if it exists + useEffect(() => { + if (!isDropDownClosed) { + let newIndex = 0; + if (selected && selected?.label) { + const selectedIndex = filteredOptions.findIndex((option: GenericObject) => option.label === selected.label); + if (selectedIndex >= 0) { + newIndex = selectedIndex; + } + } + setFocusedOptionIndex(newIndex); } -
{ - // Debounce to delay the execution to prevent jumpiness in Focus state - setTimeout(() => { - if (!dropdownRef.current.contains(document.activeElement)) { - setIsInputFocused(false); - } - }, 0); - }} - onFocus={() => setIsInputFocused(true)} - ref={dropdownRef} + }, [isDropDownClosed]); + + + const handleChange = (e: React.ChangeEvent) => { + setFilterItem(e.target.value); + setIsDropDownClosed(false); + }; + + const handleOptionClick = (selectedItem: GenericObject) => { + setSelected(selectedItem); + setFilterItem(""); + setIsDropDownClosed(true); + onSelect && onSelect(selectedItem); + }; + + const handleWrapperClick = () => { + autocomplete && inputRef.current.focus(); + toggleDropdown(); + }; + + const handleBackspace = () => { + setSelected({}); + onSelect && onSelect(null); + setFocusedOptionIndex(-1); + }; + + const componentsToRender = prepareSubcomponents({ + children, + hasTriggerSubcomponent, + hasContainerSubcomponent, + trigger, + container, + otherChildren, + dark + }); + + + return ( +
- {children ? ( - <> - {componentsToRender.map((component, index) => ( - {component} - ))} - - ) : ( - <> - - - {options && - options?.map((option: GenericObject) => ( - - ))} - - - )} + + {label && + + } +
{ + // Debounce to delay the execution to prevent jumpiness in Focus state + setTimeout(() => { + if (!dropdownRef.current.contains(document.activeElement)) { + setIsInputFocused(false); + } + }, 0); + }} + onFocus={() => setIsInputFocused(true)} + ref={dropdownRef} + > + {children ? ( + <> + {componentsToRender.map((component, index) => ( + {component} + ))} + + ) : ( + <> + + + {options && + options?.map((option: GenericObject) => ( + + ))} + + + )} + + {error && + + } +
+
- -
- ) + ) }; Dropdown.Option = DropdownOption; diff --git a/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_error.html.erb b/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_error.html.erb new file mode 100644 index 0000000000..0af4c58a1a --- /dev/null +++ b/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_error.html.erb @@ -0,0 +1,9 @@ +<% + options = [ + { label: 'United States', value: 'United States', id: 'us' }, + { label: 'Canada', value: 'Canada', id: 'ca' }, + { label: 'Pakistan', value: 'Pakistan', id: 'pk' }, + ] +%> + +<%= pb_rails("dropdown", props: { error: "Please make a valid selection", options: options }) %> diff --git a/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_error.jsx b/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_error.jsx new file mode 100644 index 0000000000..dfe59db73e --- /dev/null +++ b/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_error.jsx @@ -0,0 +1,34 @@ +import React, { useState } from 'react' +import { Dropdown } from '../../' + +const DropdownError = (props) => { + const [selectedOption, setSelectedOption] = useState() + const error = selectedOption?.value ? null : "Please make a valid selection" + const options = [ + { + label: "United States", + value: "United States", + }, + { + label: "Canada", + value: "Canada", + }, + { + label: "Pakistan", + value: "Pakistan", + } + ] + + return ( + <> + setSelectedOption(selectedItem)} + options={options} + {...props} + /> + + ) +} + +export default DropdownError 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 10b41941a2..b2a2557c95 100644 --- a/playbook/app/pb_kits/playbook/pb_dropdown/docs/example.yml +++ b/playbook/app/pb_kits/playbook/pb_dropdown/docs/example.yml @@ -7,6 +7,7 @@ examples: - dropdown_with_custom_display_rails: Custom Display - dropdown_with_custom_trigger_rails: Custom Trigger - dropdown_with_custom_padding: Custom Option Padding + - dropdown_error: Dropdown with Error react: - dropdown_default: Default @@ -16,6 +17,7 @@ examples: - dropdown_with_custom_display: Custom Display - dropdown_with_custom_trigger: Custom Trigger - dropdown_with_custom_padding: Custom Option Padding + - dropdown_error: Dropdown with Error # - dropdown_with_autocomplete: Autocomplete # - dropdown_with_autocomplete_and_custom_display: Autocomplete with Custom Display # - dropdown_with_external_control: useDropdown Hook 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 ca2a72ae94..637c6f517f 100644 --- a/playbook/app/pb_kits/playbook/pb_dropdown/docs/index.js +++ b/playbook/app/pb_kits/playbook/pb_dropdown/docs/index.js @@ -8,4 +8,5 @@ export { default as DropdownWithCustomPadding } from './_dropdown_with_custom_pa export { default as DropdownWithLabel } from './_dropdown_with_label.jsx' export { default as DropdownWithExternalControl } from './_dropdown_with_external_control.jsx' export { default as DropdownWithHook } from './_dropdown_with_hook.jsx' -export { default as DropdownSubcomponentStructure } from './_dropdown_subcomponent_structure.jsx' \ No newline at end of file +export { default as DropdownSubcomponentStructure } from './_dropdown_subcomponent_structure.jsx' +export { default as DropdownError } from './_dropdown_error.jsx' \ No newline at end of file diff --git a/playbook/app/pb_kits/playbook/pb_dropdown/dropdown.html.erb b/playbook/app/pb_kits/playbook/pb_dropdown/dropdown.html.erb index 98864d4ce8..9a3257f00d 100644 --- a/playbook/app/pb_kits/playbook/pb_dropdown/dropdown.html.erb +++ b/playbook/app/pb_kits/playbook/pb_dropdown/dropdown.html.erb @@ -7,20 +7,22 @@ <% if object.label.present? %> <%= pb_rails("caption", props: {text: object.label, margin_bottom:"xs"}) %> <% end %> -