diff --git a/components/select/src/simple-single-select-field/simple-single-select-field.js b/components/select/src/simple-single-select-field/simple-single-select-field.js index 453ce6482..2f053e827 100644 --- a/components/select/src/simple-single-select-field/simple-single-select-field.js +++ b/components/select/src/simple-single-select-field/simple-single-select-field.js @@ -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), diff --git a/components/select/src/simple-single-select-field/simple-single-select-field.prod.stories.js b/components/select/src/simple-single-select-field/simple-single-select-field.prod.stories.js index d8b04d504..b39c9c786 100644 --- a/components/select/src/simple-single-select-field/simple-single-select-field.prod.stories.js +++ b/components/select/src/simple-single-select-field/simple-single-select-field.prod.stories.js @@ -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, @@ -186,3 +193,45 @@ export const InputWidth = () => { ) } + +export const WithMenuMinWidth = () => { + const [value, setValue] = useState('') + const valueLabel = value + ? longOptions.find((option) => option.value === value)?.label + : '' + + return ( +
+ setValue(nextValue)} + options={longOptions} + menuMinWidth="240px" + /> +
+ ) +} + +export const WithMenuMaxWidth = () => { + const [value, setValue] = useState('') + const valueLabel = value + ? longOptions.find((option) => option.value === value)?.label + : '' + + return ( +
+ setValue(nextValue)} + options={longOptions} + menuMaxWidth="200px" + /> +
+ ) +} diff --git a/components/select/src/simple-single-select-field/simple-single-select-field.test.js b/components/select/src/simple-single-select-field/simple-single-select-field.test.js index e52ce3e10..2b2d853da 100644 --- a/components/select/src/simple-single-select-field/simple-single-select-field.test.js +++ b/components/select/src/simple-single-select-field/simple-single-select-field.test.js @@ -52,6 +52,8 @@ describe('', () => { loading={false} menuLoadingText="" menuMaxHeight="" + menuMaxWidth="400px" + menuMinWidth="240px" noMatchText="" optionUpdateStrategy="off" placeholder="" @@ -91,6 +93,8 @@ describe('', () => { 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' diff --git a/components/select/src/simple-single-select/__stories__/WithMenuMaxWidth.js b/components/select/src/simple-single-select/__stories__/WithMenuMaxWidth.js new file mode 100644 index 000000000..4c23cb1b7 --- /dev/null +++ b/components/select/src/simple-single-select/__stories__/WithMenuMaxWidth.js @@ -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 ( +
+ +
+ ) +} diff --git a/components/select/src/simple-single-select/__stories__/WithMenuMinWidth.js b/components/select/src/simple-single-select/__stories__/WithMenuMinWidth.js new file mode 100644 index 000000000..589cfcc64 --- /dev/null +++ b/components/select/src/simple-single-select/__stories__/WithMenuMinWidth.js @@ -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 ( +
+ +
+ ) +} diff --git a/components/select/src/simple-single-select/menu/menu.js b/components/select/src/simple-single-select/menu/menu.js index 507bbc5cc..f64a0b785 100644 --- a/components/select/src/simple-single-select/menu/menu.js +++ b/components/select/src/simple-single-select/menu/menu.js @@ -26,6 +26,8 @@ export function Menu({ loading, loadingText, maxHeight, + maxWidth, + minWidth, noMatchText, optionUpdateStrategy, selectRef, @@ -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 @@ -60,6 +60,14 @@ 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 ( -
+
{isEmpty && {empty}} {hasNoFilterMatch && {noMatchText}} @@ -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']), diff --git a/components/select/src/simple-single-select/simple-single-select.js b/components/select/src/simple-single-select/simple-single-select.js index 2498d4719..808993759 100644 --- a/components/select/src/simple-single-select/simple-single-select.js +++ b/components/select/src/simple-single-select/simple-single-select.js @@ -75,6 +75,8 @@ export function SimpleSingleSelect({ loading = false, menuLoadingText: _menuLoadingText = '', menuMaxHeight = '288px', + menuMaxWidth, + menuMinWidth, noMatchText: _noMatchText = '', optionUpdateStrategy = 'polite', placeholder = '', @@ -261,6 +263,8 @@ export function SimpleSingleSelect({ loading={loading} loadingText={menuLoadingText} maxHeight={menuMaxHeight} + maxWidth={menuMaxWidth} + minWidth={menuMinWidth} noMatchText={noMatchText} optionUpdateStrategy={optionUpdateStrategy} options={options} @@ -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), diff --git a/components/select/src/simple-single-select/simple-single-select.prod.stories.js b/components/select/src/simple-single-select/simple-single-select.prod.stories.js index 2a29d9e45..44848b10c 100644 --- a/components/select/src/simple-single-select/simple-single-select.prod.stories.js +++ b/components/select/src/simple-single-select/simple-single-select.prod.stories.js @@ -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' diff --git a/components/select/src/simple-single-select/simple-single-select.test.js b/components/select/src/simple-single-select/simple-single-select.test.js index b3d54b976..dcc24d763 100644 --- a/components/select/src/simple-single-select/simple-single-select.test.js +++ b/components/select/src/simple-single-select/simple-single-select.test.js @@ -150,6 +150,74 @@ describe('', () => { 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( + 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( ``` -By default the dropdown menu matches the input's width. When the input is -sized to its content, this can make the menu too narrow to read longer options -comfortably. `menuMinWidth` and `menuMaxWidth`, available on -`SingleSelectField`/`SingleSelect` and `MultiSelectField`/`MultiSelect`, let you decouple the menu width from the input: +By default the dropdown menu matches the input's width. When the input is sized to its content, this can make the menu too narrow to read longer options comfortably. `menuMinWidth` and `menuMaxWidth`, available on `SingleSelectField`/`SingleSelect`, `MultiSelectField`/`MultiSelect` and `SimpleSingleSelectField`/`SimpleSingleSelect`, let you decouple the menu width from the input: -- `menuMinWidth` — the menu grows to fit its content (`fit-content`) but is - never narrower than the greater of the input width and this value. -- `menuMaxWidth` — caps how wide the menu may grow. Useful together with - `menuMinWidth` to stop very long option labels from making the menu - excessively wide. +- `menuMinWidth` — the menu grows to fit its content (`fit-content`) but is never narrower than the greater of the input width and this value. +- `menuMaxWidth` — caps how wide the menu may grow. Useful together with `menuMinWidth` to stop very long option labels from making the menu excessively wide. It never shrinks the menu below the input width, so a `menuMaxWidth` smaller than the input has no visible effect. -Setting either prop switches the menu to `fit-content` sizing; setting neither -keeps the original behavior (menu width equals input width). +Setting either prop switches the menu to `fit-content` sizing; setting neither keeps the original behavior (menu width equals input width). :::note -These props accept any absolute or font-relative CSS length, e.g. `'200px'` or -`'20rem'`. **Percentages are not supported**: the menu is rendered in a portal, -so a percentage would resolve against the viewport rather than the input. +These props accept any absolute or font-relative CSS length, e.g. `'200px'` or `'20rem'`. **Percentages are not supported**: the menu is rendered in a portal, so a percentage would resolve against the viewport rather than the input. ::: +`SimpleSingleSelect` and `SimpleSingleSelectField` have no `inputWidth` prop — the select is sized by its container, and the menu uses that measured width: as its own width by default, and as the lower bound `menuMinWidth` is compared against. + + + +```jsx +
+ +
+``` + ## Options ### Filtering