Repository: laboratoriobridge/bold (ts)

Issue:
[Combobox] Implement support to all Select props
Part of #620 

Implement remaining Select props:
- [x] `isOpen` -> `open`
- [x] ~`renderItem`~ -> This property's effect can be achieved by the following code:
```typescript
components={{
      Item: (props) => (
        <ComboboxMenuItem {...props}>
          <Text color='success'>Custom {props.itemToString(props.item)}</Text>
        </ComboboxMenuItem>
      )
```
- [x] `popperProps`

Pull request:
#804 - Combobox/Select compatibility
closes #804 

Patch:
diff --git a/src/components/Combobox/Combobox.stories.tsx b/src/components/Combobox/Combobox.stories.tsx
index 0c19e309a..66d3b45ff 100644
--- a/src/components/Combobox/Combobox.stories.tsx
+++ b/src/components/Combobox/Combobox.stories.tsx
@@ -72,6 +72,7 @@ export const Default = () => (
     disabled={boolean('disabled', false)}
     openOnFocus={boolean('openOnFocus', true)}
     loading={boolean('loading', false)}
+    open={select('open', [undefined, true, false], undefined)}
     onChange={action('changed')}
     onFilterChange={action('filter changed')}
     onBlur={action('blur')}
@@ -93,6 +94,7 @@ export const Suggestion = () => (
     disabled={boolean('disabled', false)}
     openOnFocus={boolean('openOnFocus', false)}
     loading={boolean('loading', false)}
+    open={select('open', [undefined, true, false], undefined)}
     onChange={action('changed')}
     onBlur={action('blur')}
     onFocus={action('focus')}
@@ -111,6 +113,8 @@ export const Async = () => (
     clearable={boolean('clearable', true)}
     disabled={boolean('disabled', false)}
     openOnFocus={boolean('openOnFocus', true)}
+    open={select('open', [undefined, true, false], undefined)}
+    loading={boolean('loading', false)}
     onChange={action('changed')}
     onBlur={action('blur')}
     onFocus={action('focus')}
@@ -170,6 +174,8 @@ export const MultiSelect = () => {
       clearable={boolean('clearable', true)}
       disabled={boolean('disabled', false)}
       openOnFocus={boolean('openOnFocus', true)}
+      open={select('open', [undefined, true, false], undefined)}
+      loading={boolean('loading', false)}
       onChange={action('changed')}
       onFilterChange={action('filter changed')}
       onBlur={action('blur')}
@@ -193,6 +199,7 @@ export const Inline = () => (
     disabled={boolean('disabled', false)}
     showSearchBox={boolean('showSearchBox', true)}
     loading={boolean('loading', false)}
+    open={select('open', [undefined, true, false], undefined)}
     onChange={action('changed')}
     onFilterChange={action('filter changed')}
     onBlur={action('blur')}
diff --git a/src/components/Combobox/ComboboxInline.tsx b/src/components/Combobox/ComboboxInline.tsx
index 22c9ba4a1..8ab853dc8 100644
--- a/src/components/Combobox/ComboboxInline.tsx
+++ b/src/components/Combobox/ComboboxInline.tsx
@@ -1,8 +1,9 @@
 import { useSelect } from 'downshift'
 import matchSorter from 'match-sorter'
 import React, { ChangeEvent, CSSProperties, useCallback, useEffect, useRef, useState } from 'react'
-import { usePopper } from 'react-popper'
+import { PopperProps, usePopper } from 'react-popper'
 import { useMemo } from 'react'
+import { isNil } from 'lodash'
 import { Theme, useStyles } from '../../styles'
 import { composeHandlers, composeRefs } from '../../util/react'
 import { FormControl } from '../FormControl'
@@ -22,12 +23,14 @@ export interface ComboboxInlineProps<T>
   items: T[] | ((query: string) => Promise<T[]>)
   itemToString(item: T): string
   loading: boolean
+  open?: boolean
   debounceMilliseconds: number
   menuMinWidth?: number
   filter?(items: T[], filter: string): T[]
   onChange?: (newValue: T) => void
   onFilterChange?: (newValue: string) => void
   components?: Omit<Partial<ComboboxComponents<T>>, 'CreateItem'>
+  popperProps?: Omit<Partial<PopperProps<any>>, 'children'>
 
   menuId?: string
   getItemId?(index: number): string
@@ -62,6 +65,8 @@ export function ComboboxInline<T>(props: ComboboxInlineProps<T>) {
     error,
     searchBoxPlaceholder,
     showSearchBox = true,
+    open,
+    popperProps,
     ...rest
   } = props
 
@@ -86,6 +91,13 @@ export function ComboboxInline<T>(props: ComboboxInlineProps<T>) {
   const [searchBoxRef, setSearchBoxRef] = useState<HTMLInputElement>()
   const menuRef = useRef<HTMLDivElement>()
 
+  useEffect(() => {
+    if (open && !itemsLoaded) {
+      loadItems(searchBoxRef?.value)
+      setItemsLoaded(true)
+    }
+  }, [open, itemsLoaded, loadItems, searchBoxRef])
+
   const {
     selectedItem,
     isOpen,
@@ -111,6 +123,8 @@ export function ComboboxInline<T>(props: ComboboxInlineProps<T>) {
 
     menuId,
     getItemId,
+
+    ...(isNil(open) ? {} : { isOpen: open }),
   })
   const onSearchBoxValueChange = useCallback(
     ({ target: { value: inputValue } }: ChangeEvent<HTMLInputElement>) =>
@@ -140,6 +154,7 @@ export function ComboboxInline<T>(props: ComboboxInlineProps<T>) {
     attributes: { popper: popperAttributes },
   } = usePopper(toggleButtonRef.current, menuRef.current, {
     placement: 'bottom-start',
+    ...popperProps,
   })
 
   useEffect(() => {
diff --git a/src/components/Combobox/ComboboxMultiselect.tsx b/src/components/Combobox/ComboboxMultiselect.tsx
index 44f44bb62..9a54e4861 100644
--- a/src/components/Combobox/ComboboxMultiselect.tsx
+++ b/src/components/Combobox/ComboboxMultiselect.tsx
@@ -2,6 +2,7 @@ import { useCombobox, UseComboboxState, UseComboboxStateChangeOptions, useMultip
 import matchSorter from 'match-sorter'
 import React, { CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from 'react'
 import { usePopper } from 'react-popper'
+import { isNil } from 'lodash'
 import { Theme, useStyles } from '../../styles'
 import { composeHandlers, composeRefs } from '../../util/react'
 import { FormControl } from '../FormControl'
@@ -51,6 +52,8 @@ export function ComboboxMultiselect<T = DefaultComboboxItemType>(props: Combobox
     labelId,
     menuId,
     getItemId,
+    open,
+    popperProps,
     ...rest
   } = props
 
@@ -98,6 +101,13 @@ export function ComboboxMultiselect<T = DefaultComboboxItemType>(props: Combobox
     itemIsEqual,
   ])
 
+  useEffect(() => {
+    if (open && !itemsLoaded) {
+      loadItems(inputRef.current?.value)
+      setItemsLoaded(true)
+    }
+  }, [open, itemsLoaded, loadItems])
+
   const {
     isOpen,
     highlightedIndex,
@@ -129,6 +139,8 @@ export function ComboboxMultiselect<T = DefaultComboboxItemType>(props: Combobox
     labelId,
     menuId,
     getItemId,
+
+    ...(isNil(open) ? {} : { isOpen: open }),
   })
 
   const downshiftComboboxProps = getComboboxProps()
@@ -147,6 +159,7 @@ export function ComboboxMultiselect<T = DefaultComboboxItemType>(props: Combobox
     attributes: { popper: popperAttributes },
   } = usePopper(wrapperRef.current, menuRef, {
     placement: 'bottom-start',
+    ...popperProps,
   })
 
   const formControlInputProps = getFormControlInputProps()
diff --git a/src/components/Combobox/ComboboxSingleselect.tsx b/src/components/Combobox/ComboboxSingleselect.tsx
index 931f450fa..b26e8c50e 100644
--- a/src/components/Combobox/ComboboxSingleselect.tsx
+++ b/src/components/Combobox/ComboboxSingleselect.tsx
@@ -1,8 +1,9 @@
 import { useCombobox, UseComboboxState, UseComboboxStateChangeOptions } from 'downshift'
 import matchSorter from 'match-sorter'
 import React, { CSSProperties, useCallback, useEffect, useRef, useState } from 'react'
-import { usePopper } from 'react-popper'
+import { PopperProps, usePopper } from 'react-popper'
 import { useMemo } from 'react'
+import { isNil } from 'lodash'
 import { useLocale } from '../../i18n'
 import { Theme, useStyles } from '../../styles'
 import { composeHandlers, composeRefs } from '../../util/react'
@@ -23,12 +24,14 @@ export interface ComboboxSingleselectProps<T>
   createNewItem?(inputValue: string): T
   openOnFocus?: boolean
   loading: boolean
+  open?: boolean
   debounceMilliseconds: number
   menuMinWidth?: number
   filter?(items: T[], filter: string): T[]
   onChange?: (newValue: T) => void
   onFilterChange?: (newValue: string) => void
   components?: Partial<ComboboxComponents<T>>
+  popperProps?: Omit<Partial<PopperProps<any>>, 'children'>
 
   inputId?: string
   labelId?: string
@@ -60,6 +63,8 @@ export function ComboboxSingleselect<T = DefaultComboboxItemType>(props: Combobo
     labelId,
     menuId,
     getItemId,
+    open,
+    popperProps,
     ...rest
   } = props
 
@@ -84,6 +89,13 @@ export function ComboboxSingleselect<T = DefaultComboboxItemType>(props: Combobo
   const inputRef = useRef<HTMLInputElement>()
   const [menuRef, setMenuRef] = useState<HTMLDivElement>()
 
+  useEffect(() => {
+    if (open && !itemsLoaded) {
+      loadItems(inputRef.current?.value)
+      setItemsLoaded(true)
+    }
+  }, [open, itemsLoaded, loadItems])
+
   const {
     isOpen,
     highlightedIndex,
@@ -116,6 +128,8 @@ export function ComboboxSingleselect<T = DefaultComboboxItemType>(props: Combobo
     labelId,
     menuId,
     getItemId,
+
+    ...(isNil(open) ? {} : { isOpen: open }),
   })
 
   const downshiftComboboxProps = getComboboxProps()
@@ -131,6 +145,7 @@ export function ComboboxSingleselect<T = DefaultComboboxItemType>(props: Combobo
     attributes: { popper: popperAttributes },
   } = usePopper(inputRef.current, menuRef, {
     placement: 'bottom-start',
+    ...popperProps,
   })
 
   const formControlInputProps = getFormControlInputProps()


Test patch:
diff --git a/src/components/Combobox/ComboboxInline.test.tsx b/src/components/Combobox/ComboboxInline.test.tsx
index d18832435..539ea9492 100644
--- a/src/components/Combobox/ComboboxInline.test.tsx
+++ b/src/components/Combobox/ComboboxInline.test.tsx
@@ -367,6 +367,44 @@ it('should accept actions inside children prop', async () => {
   expect(click).toHaveBeenCalledTimes(1)
 })
 
+test.each`
+  async
+  ${true}
+  ${false}
+`('keeps menu open based on the `open` prop(async: $async)', async ({ async }) => {
+  let baseElement: RenderResult['baseElement']
+  let rerender: RenderResult['rerender']
+  await act(async () => {
+    const result = render(<ComboboxInlineTest async={async} open />)
+    baseElement = result.baseElement
+    rerender = result.rerender
+  })
+
+  // initial state has open menu
+  expect(baseElement.querySelector('ul')).toBeTruthy()
+
+  const button = baseElement.querySelector('button')
+  await act(async () => {
+    fireEvent.click(button)
+  })
+
+  expect(baseElement.querySelector('ul')).toBeTruthy()
+  //click dropdown button
+  await act(async () => {
+    fireEvent.click(baseElement)
+  })
+  expect(baseElement.querySelector('ul')).toBeTruthy()
+
+  // rerenders switching prop
+  await act(async () => rerender(<ComboboxInlineTest async={async} open={false} />))
+  expect(baseElement.querySelector('ul')).toBeFalsy()
+
+  await act(async () => {
+    fireEvent.click(button)
+  })
+  expect(baseElement.querySelector('ul')).toBeFalsy()
+})
+
 describe('rendering', () => {
   it('renders correcly closed', async () => {
     let baseElement: RenderResult['baseElement']
diff --git a/src/components/Combobox/ComboboxMultiselect.test.tsx b/src/components/Combobox/ComboboxMultiselect.test.tsx
index f1fd34781..6665dc739 100644
--- a/src/components/Combobox/ComboboxMultiselect.test.tsx
+++ b/src/components/Combobox/ComboboxMultiselect.test.tsx
@@ -582,6 +582,40 @@ it('should accept actions inside children prop', async () => {
   expect(click).toHaveBeenCalledTimes(1)
 })
 
+test.each`
+  async
+  ${true}
+  ${false}
+`('keeps menu open based on the `open` prop(async: $async)', async ({ async }) => {
+  let baseElement: RenderResult['baseElement']
+  let rerender: RenderResult['rerender']
+  await act(async () => {
+    const result = render(<ComboboxTest async={async} open />)
+    baseElement = result.baseElement
+    rerender = result.rerender
+  })
+  const input = baseElement.querySelector('input')
+
+  // initial state has open menu
+  expect(baseElement.querySelector('ul')).toBeTruthy()
+
+  // blur input
+  await act(async () => {
+    fireEvent.blur(input)
+  })
+  expect(baseElement.querySelector('ul')).toBeTruthy()
+
+  // rerenders switching prop
+  await act(async () => rerender(<ComboboxTest async={async} open={false} />))
+  expect(baseElement.querySelector('ul')).toBeFalsy()
+
+  // focus input
+  await act(async () => {
+    fireEvent.focus(input)
+  })
+  expect(baseElement.querySelector('ul')).toBeFalsy()
+})
+
 describe('rendering', () => {
   it('renders correcly closed', async () => {
     let baseElement: RenderResult['baseElement']
diff --git a/src/components/Combobox/ComboboxSingleselect.test.tsx b/src/components/Combobox/ComboboxSingleselect.test.tsx
index 6b5cb5a19..d87ed4e80 100644
--- a/src/components/Combobox/ComboboxSingleselect.test.tsx
+++ b/src/components/Combobox/ComboboxSingleselect.test.tsx
@@ -566,6 +566,51 @@ it('should accept actions inside children prop', async () => {
   expect(click).toHaveBeenCalledTimes(1)
 })
 
+test.each`
+  async
+  ${true}
+  ${false}
+`('keeps menu open based on the `open` prop(async: $async)', async ({ async }) => {
+  let baseElement: RenderResult['baseElement']
+  let rerender: RenderResult['rerender']
+  await act(async () => {
+    const result = render(<ComboboxTest async={async} open />)
+    baseElement = result.baseElement
+    rerender = result.rerender
+  })
+  const input = baseElement.querySelector('input')
+
+  // initial state has open menu
+  expect(baseElement.querySelector('ul')).toBeTruthy()
+  const dropdownButton = baseElement.querySelector('button')
+
+  // blur input
+  await act(async () => {
+    fireEvent.blur(input)
+  })
+  expect(baseElement.querySelector('ul')).toBeTruthy()
+  //click dropdown button
+  await act(async () => {
+    fireEvent.click(dropdownButton)
+  })
+  expect(baseElement.querySelector('ul')).toBeTruthy()
+
+  // rerenders switching prop
+  await act(async () => rerender(<ComboboxTest async={async} open={false} />))
+  expect(baseElement.querySelector('ul')).toBeFalsy()
+
+  // focus input
+  await act(async () => {
+    fireEvent.focus(input)
+  })
+  expect(baseElement.querySelector('ul')).toBeFalsy()
+  //click dropdown button
+  await act(async () => {
+    fireEvent.click(dropdownButton)
+  })
+  expect(baseElement.querySelector('ul')).toBeFalsy()
+})
+
 describe('rendering', () => {
   it('renders correcly closed', async () => {
     let baseElement: RenderResult['baseElement']
diff --git a/src/components/Combobox/__snapshots__/ComboboxInline.test.tsx.snap b/src/components/Combobox/__snapshots__/ComboboxInline.test.tsx.snap
index 186c461a0..eba5f229b 100644
--- a/src/components/Combobox/__snapshots__/ComboboxInline.test.tsx.snap
+++ b/src/components/Combobox/__snapshots__/ComboboxInline.test.tsx.snap
@@ -107,7 +107,9 @@ exports[`rendering renders correcly closed 1`] = `
     id="a11y-status-message"
     role="status"
     style="border: 0px; height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px;"
-  />
+  >
+    12 results are available, use up and down arrow keys to navigate. Press Enter or Space Bar keys to select.
+  </div>
   <div>
     <div>
       <div
@@ -123,15 +125,15 @@ exports[`rendering renders correcly closed 1`] = `
               aria-controls="test-menu-id"
               aria-expanded="false"
               aria-haspopup="listbox"
-              aria-labelledby="downshift-153-label"
+              aria-labelledby="downshift-195-label"
               class="emotion-2"
-              id="downshift-153-toggle-button"
+              id="downshift-195-toggle-button"
               type="button"
             >
               <span>
                 <label
                   class="emotion-0"
-                  id="downshift-153-label"
+                  id="downshift-195-label"
                 >
                   Fruits
                 </label>
@@ -156,7 +158,7 @@ exports[`rendering renders correcly closed 1`] = `
       <div
         aria-busy="false"
         aria-invalid="false"
-        aria-labelledby="downshift-153-label"
+        aria-labelledby="downshift-195-label"
         id="test-menu-id"
         role="listbox"
         tabindex="-1"
@@ -446,7 +448,9 @@ exports[`rendering renders correcly opened 1`] = `
     id="a11y-status-message"
     role="status"
     style="border: 0px; height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px;"
-  />
+  >
+    12 results are available, use up and down arrow keys to navigate. Press Enter or Space Bar keys to select.
+  </div>
   <div>
     <div>
       <div
@@ -462,15 +466,15 @@ exports[`rendering renders correcly opened 1`] = `
               aria-controls="test-menu-id"
               aria-expanded="true"
               aria-haspopup="listbox"
-              aria-labelledby="downshift-154-label"
+              aria-labelledby="downshift-196-label"
               class="emotion-2"
-              id="downshift-154-toggle-button"
+              id="downshift-196-toggle-button"
               type="button"
             >
               <span>
                 <label
                   class="emotion-0"
-                  id="downshift-154-label"
+                  id="downshift-196-label"
                 >
                   Fruits
                 </label>
@@ -495,7 +499,7 @@ exports[`rendering renders correcly opened 1`] = `
       <div
         aria-busy="false"
         aria-invalid="false"
-        aria-labelledby="downshift-154-label"
+        aria-labelledby="downshift-196-label"
         id="test-menu-id"
         role="listbox"
         tabindex="-1"
@@ -1005,15 +1009,15 @@ exports[`rendering renders correcly opened and loading 1`] = `
               aria-controls="test-menu-id"
               aria-expanded="true"
               aria-haspopup="listbox"
-              aria-labelledby="downshift-166-label"
+              aria-labelledby="downshift-208-label"
               class="emotion-2"
-              id="downshift-166-toggle-button"
+              id="downshift-208-toggle-button"
               type="button"
             >
               <span>
                 <label
                   class="emotion-0"
-                  id="downshift-166-label"
+                  id="downshift-208-label"
                 >
                   Button text
                 </label>
@@ -1038,7 +1042,7 @@ exports[`rendering renders correcly opened and loading 1`] = `
       <div
         aria-busy="true"
         aria-invalid="false"
-        aria-labelledby="downshift-166-label"
+        aria-labelledby="downshift-208-label"
         id="test-menu-id"
         role="listbox"
         tabindex="-1"
@@ -1582,19 +1586,19 @@ exports[`rendering renders correcly with custom components correctly 1`] = `
             class="emotion-3"
           >
             <button
-              aria-controls="downshift-178-menu"
+              aria-controls="downshift-220-menu"
               aria-expanded="true"
               aria-haspopup="listbox"
-              aria-labelledby="downshift-178-label"
+              aria-labelledby="downshift-220-label"
               class="emotion-2"
-              id="downshift-178-toggle-button"
+              id="downshift-220-toggle-button"
               name="fruit"
               type="button"
             >
               <span>
                 <label
                   class="emotion-0"
-                  id="downshift-178-label"
+                  id="downshift-220-label"
                 >
                   Fruit
                 </label>
@@ -1619,8 +1623,8 @@ exports[`rendering renders correcly with custom components correctly 1`] = `
       <div
         aria-busy="false"
         aria-invalid="false"
-        aria-labelledby="downshift-178-label"
-        id="downshift-178-menu"
+        aria-labelledby="downshift-220-label"
+        id="downshift-220-menu"
         role="listbox"
         tabindex="-1"
       >