Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,12 @@ SimpleSingleSelectField.propTypes = {
/** Allows to modify the max height of the menu **/
menuMaxHeight: PropTypes.string,

/** See [dropdown menu width](https://developers.dhis2.org/docs/ui/components/select#dropdown-menu-width) **/
menuMaxWidth: PropTypes.string,

/** See [dropdown menu width](https://developers.dhis2.org/docs/ui/components/select#dropdown-menu-width) **/
menuMinWidth: PropTypes.string,

/** String that will be displayed when the select is being filtered but the options array is empty **/
noMatchText: requiredIf((props) => props.filterable, PropTypes.string),

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ const options = [
{ value: '10', label: 'ten' },
]

const longOptions = [
{ value: '1', label: 'option one' },
{ value: '2', label: 'option two' },
{ value: '3', label: 'option three' },
{ value: '4', label: 'A longer option that exceeds the minimum' },
]

export default {
title: 'SimpleSingleSelectField',
component: SimpleSingleSelectField,
Expand Down Expand Up @@ -186,3 +193,45 @@ export const InputWidth = () => {
</div>
)
}

export const WithMenuMinWidth = () => {
const [value, setValue] = useState('')
const valueLabel = value
? longOptions.find((option) => option.value === value)?.label
: ''

return (
<div style={{ width: 120 }}>
<SimpleSingleSelectField
name="simple"
label="This is the label"
value={value}
valueLabel={valueLabel}
onChange={(nextValue) => setValue(nextValue)}
options={longOptions}
menuMinWidth="240px"
/>
</div>
)
}

export const WithMenuMaxWidth = () => {
const [value, setValue] = useState('')
const valueLabel = value
? longOptions.find((option) => option.value === value)?.label
: ''

return (
<div style={{ width: 120 }}>
<SimpleSingleSelectField
name="simple"
label="This is the label"
value={value}
valueLabel={valueLabel}
onChange={(nextValue) => setValue(nextValue)}
options={longOptions}
menuMaxWidth="200px"
/>
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ describe('<SimpleSingleSelectField />', () => {
loading={false}
menuLoadingText=""
menuMaxHeight=""
menuMaxWidth="400px"
menuMinWidth="240px"
noMatchText=""
optionUpdateStrategy="off"
placeholder=""
Expand Down Expand Up @@ -91,6 +93,8 @@ describe('<SimpleSingleSelectField />', () => {
expect(SimpleSingleSelect.mock.calls[0][0].loading).toBe(false)
expect(SimpleSingleSelect.mock.calls[0][0].menuLoadingText).toBe('')
expect(SimpleSingleSelect.mock.calls[0][0].menuMaxHeight).toBe('')
expect(SimpleSingleSelect.mock.calls[0][0].menuMaxWidth).toBe('400px')
expect(SimpleSingleSelect.mock.calls[0][0].menuMinWidth).toBe('240px')
expect(SimpleSingleSelect.mock.calls[0][0].noMatchText).toBe('')
expect(SimpleSingleSelect.mock.calls[0][0].optionUpdateStrategy).toBe(
'off'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import React, { useState } from 'react'
import { SimpleSingleSelect } from '../simple-single-select.js'

const options = [
{ value: '1', label: 'option one' },
{ value: '2', label: 'option two' },
{ value: '3', label: 'A much longer option label that gets clamped' },
]

export const WithMenuMaxWidth = () => {
const [selected, setSelected] = useState(null)

return (
<div style={{ width: 120 }}>
<SimpleSingleSelect
name="simple"
selected={selected}
onChange={setSelected}
options={options}
menuMaxWidth="200px"
/>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import React, { useState } from 'react'
import { SimpleSingleSelect } from '../simple-single-select.js'

const options = [
{ value: '1', label: 'option one' },
{ value: '2', label: 'option two' },
{ value: '3', label: 'option three' },
{ value: '4', label: 'A longer option that exceeds the minimum' },
]

export const WithMenuMinWidth = () => {
const [selected, setSelected] = useState(null)

return (
<div style={{ width: 120 }}>
<SimpleSingleSelect
name="simple"
selected={selected}
onChange={setSelected}
options={options}
menuMinWidth="240px"
/>
</div>
)
}
34 changes: 26 additions & 8 deletions components/select/src/simple-single-select/menu/menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export function Menu({
loading,
loadingText,
maxHeight,
maxWidth,
minWidth,
noMatchText,
optionUpdateStrategy,
selectRef,
Expand All @@ -34,18 +36,16 @@ export function Menu({
onClose,
onEndReached,
}) {
const [menuWidth, setWidth] = useState('auto')
const [selectWidth, setSelectWidth] = useState()
const dataTestPrefix = `${dataTest}-menu`

// Re-measuring whenever `hidden` changes keeps the width current when the
// select's container has been resized since the menu was last opened
useEffect(() => {
if (selectRef) {
const callback = () => setWidth(`${selectRef.offsetWidth}px`)
callback() // We want to know the width as soon as the

selectRef.addEventListener('resize', callback)
return () => selectRef.removeEventListener('resize', callback)
setSelectWidth(`${selectRef.offsetWidth}px`)
}
}, [selectRef])
}, [selectRef, hidden])

if (hidden) {
return null
Expand All @@ -60,14 +60,30 @@ export function Menu({

const isEmpty = !options.length && !filterValue

const flexible = Boolean(minWidth || maxWidth)
// We never want the menu narrower than the select, so a maxWidth below
// the select's width intentionally has no effect
const flexibleMinWidth =
minWidth && selectWidth
? `max(${selectWidth}, ${minWidth})`
: minWidth || selectWidth

return (
<Layer onBackdropClick={onClose} transparent>
<Popper
reference={selectRef}
placement="bottom-start"
observeReferenceResize
>
<div className="menu" style={{ width: menuWidth, maxHeight }}>
<div
className="menu"
style={{
width: flexible ? 'fit-content' : selectWidth,
minWidth: flexible ? flexibleMinWidth : undefined,
maxWidth,
maxHeight,
}}
>
{isEmpty && <Empty>{empty}</Empty>}

{hasNoFilterMatch && <NoMatch>{noMatchText}</NoMatch>}
Expand Down Expand Up @@ -160,6 +176,8 @@ Menu.propTypes = {
loading: PropTypes.bool,
loadingText: PropTypes.string,
maxHeight: PropTypes.string,
maxWidth: PropTypes.string,
minWidth: PropTypes.string,
noMatchText: PropTypes.string,
optionComponent: PropTypes.elementType,
optionUpdateStrategy: PropTypes.oneOf(['off', 'polite', 'assertive']),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ export function SimpleSingleSelect({
loading = false,
menuLoadingText: _menuLoadingText = '',
menuMaxHeight = '288px',
menuMaxWidth,
menuMinWidth,
noMatchText: _noMatchText = '',
optionUpdateStrategy = 'polite',
placeholder = '',
Expand Down Expand Up @@ -261,6 +263,8 @@ export function SimpleSingleSelect({
loading={loading}
loadingText={menuLoadingText}
maxHeight={menuMaxHeight}
maxWidth={menuMaxWidth}
minWidth={menuMinWidth}
noMatchText={noMatchText}
optionUpdateStrategy={optionUpdateStrategy}
options={options}
Expand Down Expand Up @@ -342,6 +346,12 @@ SimpleSingleSelect.propTypes = {
/** Allows to modify the max height of the menu **/
menuMaxHeight: PropTypes.string,

/** See [dropdown menu width](https://developers.dhis2.org/docs/ui/components/select#dropdown-menu-width) **/
menuMaxWidth: PropTypes.string,

/** See [dropdown menu width](https://developers.dhis2.org/docs/ui/components/select#dropdown-menu-width) **/
menuMinWidth: PropTypes.string,

/** String that will be displayed when the select is being filtered but the options array is empty **/
noMatchText: requiredIf((props) => props.filterable, PropTypes.string),

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export { WithOptionsAndLoadingText } from './__stories__/WithOptionsAndLoadingTe
export { WithoutOptionsAndLoading } from './__stories__/WithoutOptionsAndLoading.js'
export { WithManyOptions } from './__stories__/WithManyOptions.js'
export { WithCustomLowMaxHeight } from './__stories__/WithCustomLowMaxHeight.js'
export { WithMenuMinWidth } from './__stories__/WithMenuMinWidth.js'
export { WithMenuMaxWidth } from './__stories__/WithMenuMaxWidth.js'
export { WithOptionsAndDisabled } from './__stories__/WithOptionsAndDisabled.js'
export { WithSelectionAndDisabled } from './__stories__/WithSelectionAndDisabled.js'
export { WithPrefix } from './__stories__/WithPrefix.js'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,74 @@ describe('<SimpleSingleSelect />', () => {
expect(menu.style.maxHeight).toBe('100px')
})

describe('dropdown menu width', () => {
let offsetWidth

beforeEach(() => {
// The menu's width is derived from the select's measured width,
// which is always 0 in jsdom
offsetWidth = jest
.spyOn(HTMLElement.prototype, 'offsetWidth', 'get')
.mockReturnValue(120)
})

afterEach(() => {
offsetWidth.mockRestore()
})

const renderAndOpen = (props) => {
render(
<SimpleSingleSelect
name="simple"
selected={{ value: 'foo', label: 'Foo' }}
onChange={() => null}
options={[{ value: 'foo', label: 'Foo' }]}
{...props}
/>
)

fireEvent.click(screen.getByRole('combobox'))

const listbox = screen.getByRole('listbox')
return listbox.parentNode.parentNode.parentNode
}

it('should match the width of the select by default', () => {
const menu = renderAndOpen()

expect(menu.style.width).toBe('120px')
})

it('should not be narrower than menuMinWidth or the select', () => {
const menu = renderAndOpen({ menuMinWidth: '240px' })

expect(menu.style.minWidth).toBe('max(120px, 240px)')
})

it('should not be wider than menuMaxWidth', () => {
const menu = renderAndOpen({ menuMaxWidth: '200px' })

expect(menu.style.maxWidth).toBe('200px')
expect(menu.style.minWidth).toBe('120px')
})

it('should re-measure the select when the menu is reopened', () => {
renderAndOpen({ menuMinWidth: '240px' })
const comboBox = screen.getByRole('combobox')

// close the menu
fireEvent.click(comboBox)

// widen the select while the menu is closed, then reopen it
offsetWidth.mockReturnValue(300)
fireEvent.click(comboBox)

const listbox = screen.getByRole('listbox')
const menu = listbox.parentNode.parentNode.parentNode
expect(menu.style.minWidth).toBe('max(300px, 240px)')
})
})

it('should accept a placeholder', () => {
render(
<SimpleSingleSelect
Expand Down
Loading
Loading