diff --git a/docs/en-US/component/select-v2.md b/docs/en-US/component/select-v2.md index 26833f43b4..532792ea64 100644 --- a/docs/en-US/component/select-v2.md +++ b/docs/en-US/component/select-v2.md @@ -213,6 +213,16 @@ select-v2/custom-label ::: +## Custom Width ^(2.9.2) + +The width of dropdown box is calculated by default based on the value of `label`. If you customize the dropdown box options through the `default slot`, it is likely that the text displayed in the options is not equal to the value of `label`, resulting in calculation errors. In this case, you can set the `fit-input-width` attribute to a number to fix its width. + +:::demo + +select-v2/custom-width + +::: + ## API ### Attributes @@ -248,6 +258,7 @@ select-v2/custom-label | persistent | when select dropdown is inactive and `persistent` is `false`, select dropdown will be destroyed | ^[boolean] | true | | popper-options | [popper.js](https://popper.js.org/docs/v2/) parameters | ^[object]refer to [popper.js](https://popper.js.org/docs/v2/) doc | {} | | automatic-dropdown | for non-filterable Select, this prop decides if the option menu pops up when the input is focused | ^[boolean] | false | +| fit-input-width ^(2.9.2) | whether the width of the dropdown is the same as the input, if the value is `number`, then the width is fixed | ^[boolean] / ^[number] | true | | height | The height of the dropdown panel, 34px for each item | ^[number] | 274 | | item-height | The height of the dropdown item | ^[number] | 34 | | scrollbar-always-on | Controls whether the scrollbar is always displayed | ^[boolean] | false | diff --git a/docs/examples/select-v2/custom-width.vue b/docs/examples/select-v2/custom-width.vue new file mode 100644 index 0000000000..494bcf7e95 --- /dev/null +++ b/docs/examples/select-v2/custom-width.vue @@ -0,0 +1,43 @@ + + + diff --git a/packages/components/select-v2/__tests__/select.test.ts b/packages/components/select-v2/__tests__/select.test.ts index 2a21bafb2f..f628576578 100644 --- a/packages/components/select-v2/__tests__/select.test.ts +++ b/packages/components/select-v2/__tests__/select.test.ts @@ -1,6 +1,6 @@ // @ts-nocheck import { nextTick, ref } from 'vue' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import { NOOP, hasClass } from '@element-plus/utils' import { EVENT_CODE } from '@element-plus/constants' import { makeMountFunc } from '@element-plus/test-utils/make-mount' @@ -195,6 +195,10 @@ const PLACEHOLDER_CLASS_NAME = 'el-select__placeholder' const DEFAULT_PLACEHOLDER = 'Select' describe('Select', () => { + beforeAll(() => { + HTMLCanvasElement.prototype.getContext = vi.fn(() => null) + }) + afterEach(() => { document.body.innerHTML = '' }) diff --git a/packages/components/select-v2/src/defaults.ts b/packages/components/select-v2/src/defaults.ts index 4ae9cb901e..1085ae52e6 100644 --- a/packages/components/select-v2/src/defaults.ts +++ b/packages/components/select-v2/src/defaults.ts @@ -8,6 +8,7 @@ import { buildProps, definePropType, iconPropType, + isBoolean, isNumber, } from '@element-plus/utils' import { CHANGE_EVENT, UPDATE_MODEL_EVENT } from '@element-plus/constants' @@ -180,7 +181,7 @@ export const SelectProps = buildProps({ type: String, }, /** - * @description whether select dropdown is teleported to the body + * @description whether select dropdown is teleported, if `true` it will be teleported to where `append-to` sets */ teleported: useTooltipContentProps.teleported, /** @@ -285,6 +286,18 @@ export const SelectProps = buildProps({ * @description which element the select dropdown appends to */ appendTo: String, + /** + * @description if it is `true`, the width of the dropdown panel is the same as the input box. + * if it is `false`, the width is automatically calculated based on the value of `label`, + * or it can be set to a number to make it a fixed width + */ + fitInputWidth: { + type: [Boolean, Number], + default: true, + validator(val) { + return isBoolean(val) || isNumber(val) + }, + }, ...useEmptyValuesProps, ...useAriaProps(['ariaLabel']), } as const) diff --git a/packages/components/select-v2/src/useSelect.ts b/packages/components/select-v2/src/useSelect.ts index dfe9463cdb..49161b3301 100644 --- a/packages/components/select-v2/src/useSelect.ts +++ b/packages/components/select-v2/src/useSelect.ts @@ -20,6 +20,7 @@ import { escapeStringRegexp, isArray, isFunction, + isNumber, isObject, } from '@element-plus/utils' import { @@ -353,7 +354,40 @@ const useSelect: useSelectReturnType = ( ) const calculatePopperSize = () => { - popperSize.value = selectRef.value?.offsetWidth || 200 + if (isNumber(props.fitInputWidth)) { + popperSize.value = props.fitInputWidth + return + } + const width = selectRef.value?.offsetWidth || 200 + if (!props.fitInputWidth && allOptions.value.length > 0) { + nextTick(() => { + popperSize.value = Math.max(width, calculateLabelMaxWidth()) + }) + } else { + popperSize.value = width + } + } + + // TODO Caching implementation + // 1. There is no need to calculate options that have already been calculated + // 2. Repeatedly expand and close when persistent is set to false, no need for repeated calculations + const calculateLabelMaxWidth = () => { + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + const selector = nsSelect.be('dropdown', 'item') + const dom = menuRef.value?.listRef?.innerRef || document + const dropdownItemEl = dom.querySelector(`.${selector}`) + if (dropdownItemEl === null || ctx === null) return 0 + const style = getComputedStyle(dropdownItemEl) + const padding = + Number.parseFloat(style.paddingLeft) + + Number.parseFloat(style.paddingRight) + ctx.font = style.font + const maxWidth = filteredOptions.value.reduce((max, option) => { + const metrics = ctx.measureText(getLabel(option)) + return Math.max(metrics.width, max) + }, 0) + return maxWidth + padding } const getGapWidth = () => { @@ -880,12 +914,22 @@ const useSelect: useSelectReturnType = ( calculatePopperSize() } + watch( + () => props.fitInputWidth, + () => { + calculatePopperSize() + } + ) + // in order to track these individually, we need to turn them into refs instead of watching the entire // reactive object which could cause perf penalty when unnecessary field gets changed the watch method will // be invoked. watch(expanded, (val) => { if (val) { + if (!props.persistent) { + calculatePopperSize() + } handleQueryChange('') } else { states.inputValue = '' @@ -937,6 +981,7 @@ const useSelect: useSelectReturnType = ( watch( () => filteredOptions.value, () => { + calculatePopperSize() return menuRef.value && nextTick(menuRef.value.resetScrollTop) } ) diff --git a/packages/components/select/src/select.ts b/packages/components/select/src/select.ts index 4e7393db23..6b53720b09 100644 --- a/packages/components/select/src/select.ts +++ b/packages/components/select/src/select.ts @@ -161,7 +161,7 @@ export const SelectProps = buildProps({ default: 1, }, /** - * @description whether select dropdown is teleported to the body + * @description whether select dropdown is teleported, if `true` it will be teleported to where `append-to` sets */ teleported: useTooltipContentProps.teleported, /**