diff --git a/components/select/src/multi-select-field/multi-select-field.js b/components/select/src/multi-select-field/multi-select-field.js index 55754af71..41bc05f64 100644 --- a/components/select/src/multi-select-field/multi-select-field.js +++ b/components/select/src/multi-select-field/multi-select-field.js @@ -46,6 +46,8 @@ class MultiSelectField extends React.Component { helpText, validationText, maxHeight, + menuMinWidth, + menuMaxWidth, inputMaxHeight, inputWidth, children, @@ -81,6 +83,8 @@ class MultiSelectField extends React.Component { selected={selected} tabIndex={tabIndex} maxHeight={maxHeight} + menuMinWidth={menuMinWidth} + menuMaxWidth={menuMaxWidth} inputMaxHeight={inputMaxHeight} onChange={onChange} onFocus={onFocus} @@ -148,6 +152,10 @@ MultiSelectField.propTypes = { loadingText: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), /** Constrains height of the MultiSelect */ maxHeight: 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, /** Text to display when there are no filter results */ noMatchText: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), /** Placeholder text when the MultiSelect is empty */ diff --git a/components/select/src/multi-select-field/multi-select-field.prod.stories.js b/components/select/src/multi-select-field/multi-select-field.prod.stories.js index 69869c2d3..b834964bb 100644 --- a/components/select/src/multi-select-field/multi-select-field.prod.stories.js +++ b/components/select/src/multi-select-field/multi-select-field.prod.stories.js @@ -135,3 +135,34 @@ DefaultFilterPlaceholderAndNoMatchText.storyName = export const DefaultLoadingText = Template.bind({}) DefaultLoadingText.args = { loading: true, ...DefaultEmpty.args } DefaultLoadingText.storyName = 'Default: loadingText' + +export const WithMenuMaxWidth = Template.bind({}) +WithMenuMaxWidth.args = { + inputWidth: '120px', + menuMaxWidth: '200px', + children: [ + , + , + , + ], +} + +export const WithMenuMinWidth = Template.bind({}) +WithMenuMinWidth.args = { + inputWidth: '120px', + menuMinWidth: '240px', + children: [ + , + , + , + , + ], +} diff --git a/components/select/src/multi-select/multi-select.js b/components/select/src/multi-select/multi-select.js index d4aa9fc42..62b24dab9 100644 --- a/components/select/src/multi-select/multi-select.js +++ b/components/select/src/multi-select/multi-select.js @@ -15,6 +15,8 @@ const MultiSelect = ({ selected = staticArr, tabIndex, maxHeight, + menuMinWidth, + menuMaxWidth, inputMaxHeight, onChange, onFocus, @@ -72,6 +74,8 @@ const MultiSelect = ({ menu={menu} tabIndex={tabIndex} maxHeight={maxHeight} + menuMinWidth={menuMinWidth} + menuMaxWidth={menuMaxWidth} onChange={onChange} onFocus={onFocus} onKeyDown={onKeyDown} @@ -131,6 +135,10 @@ MultiSelect.propTypes = { loading: PropTypes.bool, loadingText: PropTypes.string, maxHeight: 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, /** Required if `filterable` prop is `true` */ noMatchText: requiredIf((props) => props.filterable, PropTypes.string), placeholder: PropTypes.string, diff --git a/components/select/src/multi-select/multi-select.prod.stories.js b/components/select/src/multi-select/multi-select.prod.stories.js index 7dd7c440e..95749aea4 100644 --- a/components/select/src/multi-select/multi-select.prod.stories.js +++ b/components/select/src/multi-select/multi-select.prod.stories.js @@ -406,3 +406,29 @@ export const RTL = (args) => { ) } RTL.args = { selected: ['1', '2'], prefix: 'RTL text' } + +export const WithMenuMaxWidth = (args) => ( +
+ + + + + +
+) +WithMenuMaxWidth.args = { menuMaxWidth: '200px' } + +export const WithMenuMinWidth = (args) => ( +
+ + + + + + +
+) +WithMenuMinWidth.args = { menuMinWidth: '240px' } diff --git a/components/select/src/select/menu-wrapper.js b/components/select/src/select/menu-wrapper.js index 597b357a0..a5d41f18a 100644 --- a/components/select/src/select/menu-wrapper.js +++ b/components/select/src/select/menu-wrapper.js @@ -7,11 +7,23 @@ import React from 'react' const MenuWrapper = ({ children, dataTest, + inputWidth, maxHeight = '280px', - menuWidth, + menuMaxWidth, + menuMinWidth, onClick, selectRef, }) => { + // menuMinWidth or menuMaxWidth enables flexible sizing (fit-content), with + // min-width = max(input, menuMinWidth). Without them, width matches the input. + const flexible = menuMinWidth || menuMaxWidth + const width = flexible ? 'fit-content' : inputWidth + const flexibleMinWidth = menuMinWidth + ? `max(${inputWidth}, ${menuMinWidth})` + : inputWidth + const minWidth = flexible ? flexibleMinWidth : 'auto' + const maxWidth = menuMaxWidth || 'none' + return ( {` div { - width: ${menuWidth}; + width: ${width}; + min-width: ${minWidth}; + max-width: ${maxWidth}; height: auto; max-height: ${maxHeight}; overflow: auto; @@ -42,10 +56,12 @@ const MenuWrapper = ({ MenuWrapper.propTypes = { dataTest: PropTypes.string.isRequired, - menuWidth: PropTypes.string.isRequired, + inputWidth: PropTypes.string.isRequired, selectRef: PropTypes.object.isRequired, children: PropTypes.node, maxHeight: PropTypes.string, + menuMaxWidth: PropTypes.string, + menuMinWidth: PropTypes.string, onClick: PropTypes.func, } diff --git a/components/select/src/select/select.js b/components/select/src/select/select.js index 1579b24c4..ec94773b7 100644 --- a/components/select/src/select/select.js +++ b/components/select/src/select/select.js @@ -14,7 +14,7 @@ const DOWN_KEY = 40 export class Select extends Component { state = { open: false, - menuWidth: 'auto', + inputWidth: 'auto', } static defaultProps = { @@ -29,7 +29,7 @@ export class Select extends Component { this.inputRef.current.focus() } - this.setState({ menuWidth: this.getMenuWidth() }) + this.setState({ inputWidth: this.getInputWidth() }) window.addEventListener('resize', this.onResize) } @@ -47,21 +47,21 @@ export class Select extends Component { * See: https://nolanlawson.com/2018/09/25/accurately-measuring-layout-on-the-web */ onResize = debounce(() => { - const menuWidth = this.getMenuWidth() + const inputWidth = this.getInputWidth() - if (this.state.menuWidth !== menuWidth) { - this.setState({ menuWidth }) + if (this.state.inputWidth !== inputWidth) { + this.setState({ inputWidth }) } }, 50) - getMenuWidth() { + getInputWidth() { const { offsetWidth } = this.inputRef.current - const { menuWidth } = this.state + const { inputWidth } = this.state - if (offsetWidth && `${offsetWidth}px` !== menuWidth) { + if (offsetWidth && `${offsetWidth}px` !== inputWidth) { return `${offsetWidth}px` } - return menuWidth + return inputWidth } handleFocusInput = () => { @@ -81,7 +81,7 @@ export class Select extends Component { handleOpen = () => { this.setState({ open: true, - menuWidth: this.getMenuWidth(), + inputWidth: this.getInputWidth(), }) } @@ -154,7 +154,7 @@ export class Select extends Component { } render() { - const { open, menuWidth } = this.state + const { open, inputWidth } = this.state const { children, className, @@ -162,6 +162,8 @@ export class Select extends Component { onChange, tabIndex, maxHeight, + menuMinWidth, + menuMaxWidth, error, warning, valid, @@ -215,7 +217,9 @@ export class Select extends Component { onClick={this.onOutsideClick} maxHeight={maxHeight} selectRef={this.selectRef} - menuWidth={menuWidth} + inputWidth={inputWidth} + menuMinWidth={menuMinWidth} + menuMaxWidth={menuMaxWidth} dataTest={`${dataTest}-menu`} > {menu} @@ -241,6 +245,8 @@ Select.propTypes = { error: sharedPropTypes.statusPropType, initialFocus: PropTypes.bool, maxHeight: PropTypes.string, + menuMaxWidth: PropTypes.string, + menuMinWidth: PropTypes.string, tabIndex: PropTypes.string, valid: sharedPropTypes.statusPropType, warning: sharedPropTypes.statusPropType, diff --git a/components/select/src/single-select-field/single-select-field.js b/components/select/src/single-select-field/single-select-field.js index f3766e2a9..cc3ad25ea 100644 --- a/components/select/src/single-select-field/single-select-field.js +++ b/components/select/src/single-select-field/single-select-field.js @@ -45,6 +45,8 @@ class SingleSelectField extends React.Component { helpText, validationText, maxHeight, + menuMinWidth, + menuMaxWidth, inputMaxHeight, inputWidth, children, @@ -81,6 +83,8 @@ class SingleSelectField extends React.Component { selected={selected} tabIndex={tabIndex} maxHeight={maxHeight} + menuMinWidth={menuMinWidth} + menuMaxWidth={menuMaxWidth} inputMaxHeight={inputMaxHeight} onChange={onChange} onFocus={onFocus} @@ -148,6 +152,10 @@ SingleSelectField.propTypes = { loadingText: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), /** Constrains height of the SingleSelect */ maxHeight: 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, /** Text to display when there are no filter results */ noMatchText: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), /** Placeholder text when the SingleSelect is empty */ diff --git a/components/select/src/single-select-field/single-select-field.prod.stories.js b/components/select/src/single-select-field/single-select-field.prod.stories.js index cf20d6dbc..167934510 100644 --- a/components/select/src/single-select-field/single-select-field.prod.stories.js +++ b/components/select/src/single-select-field/single-select-field.prod.stories.js @@ -144,3 +144,34 @@ DefaultFilterPlaceholderAndNoMatchText.storyName = export const DefaultLoadingText = Template.bind({}) DefaultLoadingText.args = { loading: true, ...DefaultEmpty.args } DefaultLoadingText.storyName = 'Default: loadingText' + +export const WithMenuMaxWidth = Template.bind({}) +WithMenuMaxWidth.args = { + inputWidth: '120px', + menuMaxWidth: '200px', + children: [ + , + , + , + ], +} + +export const WithMenuMinWidth = Template.bind({}) +WithMenuMinWidth.args = { + inputWidth: '120px', + menuMinWidth: '240px', + children: [ + , + , + , + , + ], +} diff --git a/components/select/src/single-select/single-select.js b/components/select/src/single-select/single-select.js index 9f190e1ac..1e76b1dbf 100644 --- a/components/select/src/single-select/single-select.js +++ b/components/select/src/single-select/single-select.js @@ -13,6 +13,8 @@ const SingleSelect = ({ selected = '', tabIndex, maxHeight, + menuMinWidth, + menuMaxWidth, inputMaxHeight, onChange, onFocus, @@ -68,6 +70,8 @@ const SingleSelect = ({ menu={menu} tabIndex={tabIndex} maxHeight={maxHeight} + menuMinWidth={menuMinWidth} + menuMaxWidth={menuMaxWidth} onChange={onChange} onFocus={onFocus} onKeyDown={onKeyDown} @@ -127,6 +131,10 @@ SingleSelect.propTypes = { loading: PropTypes.bool, loadingText: PropTypes.string, maxHeight: 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, /** Text to show when filter returns no results. Required if `filterable` prop is true */ noMatchText: requiredIf((props) => props.filterable, PropTypes.string), placeholder: PropTypes.string, diff --git a/components/select/src/single-select/single-select.prod.stories.js b/components/select/src/single-select/single-select.prod.stories.js index 6039292be..b7d570de6 100644 --- a/components/select/src/single-select/single-select.prod.stories.js +++ b/components/select/src/single-select/single-select.prod.stories.js @@ -356,3 +356,29 @@ export const ShiftedIntoView = (args) => ( `} ) + +export const WithMenuMaxWidth = (args) => ( +
+ + + + + +
+) +WithMenuMaxWidth.args = { menuMaxWidth: '200px' } + +export const WithMenuMinWidth = (args) => ( +
+ + + + + + +
+) +WithMenuMinWidth.args = { menuMinWidth: '240px' } diff --git a/docs/docs/components/select.md b/docs/docs/components/select.md index d3d7c5cd9..2c786f7ac 100644 --- a/docs/docs/components/select.md +++ b/docs/docs/components/select.md @@ -150,6 +150,40 @@ Use a multi select if the user can choose one or more options. - A selects width should reflect the expected content. - Avoid full-width selects, which can be visually unclear. +#### Dropdown menu width + + + +```jsx + + + + +``` + +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: + +- `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. + +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. +::: + ## Options ### Filtering