From 2b840436307f64f9818c1143aa528a7e2cf99f7b Mon Sep 17 00:00:00 2001 From: Fadi Alhayek Date: Tue, 11 Aug 2026 21:07:35 +0300 Subject: [PATCH] Fix select menu positioning in cards Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/README.md | 1 + docs/content/examples.mdx | 65 +++++++++++++++++++++++++ docs/content/options.mdx | 11 ++++- docs/static/examples/card-header.html | 68 +++++++++++++++++++++++++++ js/bootstrap-select.api.js | 1 + js/bootstrap-select.class.js | 7 ++- js/bootstrap-select.constants.js | 2 +- js/bootstrap-select.sizing.js | 20 +++++--- sass/bootstrap-select.scss | 2 +- tests/e2e/single-select.spec.js | 64 +++++++++++++++++++++++++ 10 files changed, 231 insertions(+), 10 deletions(-) create mode 100644 docs/static/examples/card-header.html diff --git a/docs/README.md b/docs/README.md index 45701c30..b44e134e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -37,6 +37,7 @@ Open `http://localhost:3000/` after startup. The examples are available from the docs UI and directly at: - `http://localhost:3000/examples/basic.html` +- `http://localhost:3000/examples/card-header.html` - `http://localhost:3000/examples/live-search.html` - `http://localhost:3000/examples/multiple.html` diff --git a/docs/content/examples.mdx b/docs/content/examples.mdx index a2134683..09dacb26 100644 --- a/docs/content/examples.mdx +++ b/docs/content/examples.mdx @@ -21,6 +21,7 @@ The main examples now live directly on this docs page so they inherit the docs t - [Search and multi-select workflows](#search-and-multi-select-workflows) - [Selection text and summaries](#selection-text-and-summaries) - [Styling and layout](#styling-and-layout) + - [Bootstrap card with header](#bootstrap-card-with-header) - [Native Bootstrap comparison](#native-bootstrap-comparison) - [Rich option content](#rich-option-content) - [Menu behavior](#menu-behavior) @@ -546,6 +547,70 @@ The supported values are: --- +### Bootstrap card with header + +Use this layout to verify how the picker behaves when it sits directly below a Bootstrap `.card-header`. The menu is appended to `` so it renders outside the card, and auto-dropup is disabled so it does not cover the card header. + + +
+
+ Question pool + Choose which questions are available for this interview. +
+ Draft +
+
+
+ +
+
+ + +
+
+ + +
+
+
+ +
+ + +
+
+ `} +/> + +For isolated testing outside the docs page, open the standalone page: + +

+ + Open the Bootstrap card header example ↗ + +

+ ### Native Bootstrap comparison `bootstrap-select` is styled to mirror a native Bootstrap 5.3 `.form-select`: the diff --git a/docs/content/options.mdx b/docs/content/options.mdx index 27d30fe4..4bd1fa57 100644 --- a/docs/content/options.mdx +++ b/docs/content/options.mdx @@ -344,6 +344,15 @@ For security reasons, the `sanitize`, `sanitizeFn`, and `whiteList` options cann

When set to false, the picker follows the width of its container.

+ + container + string | element | false + false + +

Appends the dropdown menu to another element, such as 'body', instead of keeping it inside the picker wrapper.

+

Use this when the picker sits inside constrained UI such as a card or another element with clipping or stacking styles.

+ + sanitize boolean @@ -374,7 +383,7 @@ For security reasons, the `sanitize`, `sanitizeFn`, and `whiteList` options cann /> :::info Bootstrap 5 runtime defaults -This Bootstrap 5 build no longer supports the legacy `container`, `mobile`, `styleBase`, or `windowPadding` options. When `width` is not set, the picker follows normal Bootstrap sizing and fills its container by default. +This Bootstrap 5 build no longer supports the legacy `mobile`, `styleBase`, or `windowPadding` options. When `width` is not set, the picker follows normal Bootstrap sizing and fills its container by default. ::: ## Tags-style live search and open options diff --git a/docs/static/examples/card-header.html b/docs/static/examples/card-header.html new file mode 100644 index 00000000..da9323e2 --- /dev/null +++ b/docs/static/examples/card-header.html @@ -0,0 +1,68 @@ + + + + + + bootstrap-select card header example + + + + +
+

Bootstrap card header example

+

Use this page to verify the picker when it is rendered directly below a Bootstrap card header.

+ +
+
+
+ Question pool + Choose which questions are available for this interview. +
+ Draft +
+
+
+ +
+
+ + +
+
+ + +
+
+
+ +
+ + +
+
+
+
+ + + + diff --git a/js/bootstrap-select.api.js b/js/bootstrap-select.api.js index 1e3472d3..1ff601c4 100644 --- a/js/bootstrap-select.api.js +++ b/js/bootstrap-select.api.js @@ -497,6 +497,7 @@ Selectpicker.DEFAULTS = { showContent: true, dropupAuto: true, header: false, + container: false, liveSearch: false, liveSearchPlaceholder: null, liveSearchNormalize: false, diff --git a/js/bootstrap-select.class.js b/js/bootstrap-select.class.js index c2e487e9..a0185d4a 100644 --- a/js/bootstrap-select.class.js +++ b/js/bootstrap-select.class.js @@ -161,7 +161,7 @@ class Selectpicker { this.clickListener(); var Dropdown = getDropdown(); - this.dropdown = new Dropdown(this.button); + this.dropdown = new Dropdown(this.button, this.options.container ? { display: 'static' } : undefined); // store a reference to the instance for delegated handlers this.newElement.bootstrapSelectInstance = this; @@ -176,6 +176,11 @@ class Selectpicker { this.setStyle(); this.setWidth(); + + if (this.options.container) { + this.selectPosition(); + } + this._on(this.element, 'hide' + EVENT_KEY, function () { if (that.isVirtual()) { // empty menu on close diff --git a/js/bootstrap-select.constants.js b/js/bootstrap-select.constants.js index 5bb6e452..e24d48f5 100644 --- a/js/bootstrap-select.constants.js +++ b/js/bootstrap-select.constants.js @@ -294,7 +294,7 @@ var changedArguments = null; // shared flag for spacebar selection handling (mirrors original document data flag) var spaceSelectFlag = false; -var REMOVED_OPTIONS = ['container', 'display', 'mobile', 'styleBase', 'windowPadding']; +var REMOVED_OPTIONS = ['display', 'mobile', 'styleBase', 'windowPadding']; function stripRemovedOptions (source) { if (!source || typeof source !== 'object') return source; diff --git a/js/bootstrap-select.sizing.js b/js/bootstrap-select.sizing.js index 49509fcc..eaf28959 100644 --- a/js/bootstrap-select.sizing.js +++ b/js/bootstrap-select.sizing.js @@ -158,8 +158,15 @@ if (this.options.dropupAuto) { // Get the estimated height of the menu without scrollbars. estimate = liHeight * this.selectpicker.current.data.length + menuPadding.vert; + // Prefer opening downward whenever the menu can still show its controls + // and at least one option below the trigger. Flipping upward too early + // covers preceding form content such as card headers. + var minimumDropdownSpace = liHeight + headerHeight + searchHeight + actionsHeight + doneButtonHeight + menuPadding.vert; + var hasMeaningfulSpaceBelow = this.sizeInfo.selectOffsetBot >= minimumDropdownSpace; - isDropup = this.sizeInfo.selectOffsetTop - this.sizeInfo.selectOffsetBot > this.sizeInfo.menuExtras.vert && estimate + this.sizeInfo.menuExtras.vert + 50 > this.sizeInfo.selectOffsetBot; + isDropup = !hasMeaningfulSpaceBelow && + this.sizeInfo.selectOffsetTop - this.sizeInfo.selectOffsetBot > this.sizeInfo.menuExtras.vert && + estimate + this.sizeInfo.menuExtras.vert + 50 > this.sizeInfo.selectOffsetBot; // ensure dropup doesn't change while searching (so menu doesn't bounce back and forth) if (this.selectpicker.isSearching === true) { @@ -270,15 +277,15 @@ this.bsContainer = createFromHTML('
'); var that = this, - container = resolveContainer(this.options.container), + container = resolveContainer(this.options.container) || document.body, pos, containerPos, actualHeight, getPlacement = function (element) { var Dropdown = getDropdown(), containerPosition = {}, - // fall back to dropdown's default display setting if display is not manually set - display = that.options.display || (Dropdown.Default ? Dropdown.Default.display : false); + // relocated menus are positioned by bootstrap-select's container wrapper + display = that.dropdown && that.dropdown._config ? that.dropdown._config.display : (Dropdown.Default ? Dropdown.Default.display : false); var extraClass = element.getAttribute('class').replace(/form-control|fit-width/gi, '').trim(); if (extraClass) that.bsContainer.classList.add.apply(that.bsContainer.classList, extraClass.split(/\s+/)); @@ -296,10 +303,12 @@ actualHeight = element.classList.contains(classNames.DROPUP) ? 0 : element.offsetHeight; - // Bootstrap 5 uses Popper for menu positioning if (display === 'static') { containerPosition.top = pos.top - containerPos.top + actualHeight; containerPosition.left = pos.left - containerPos.left; + } else { + containerPosition.top = pos.top - containerPos.top; + containerPosition.left = pos.left - containerPos.left; } containerPosition.width = element.offsetWidth; @@ -489,4 +498,3 @@ } } } - diff --git a/sass/bootstrap-select.scss b/sass/bootstrap-select.scss index 439fcbe3..c10a8b52 100644 --- a/sass/bootstrap-select.scss +++ b/sass/bootstrap-select.scss @@ -696,7 +696,7 @@ select.selectpicker { border-bottom: 1px solid var(--bs-border-color-translucent, rgba(0, 0, 0, 0.1)); & + .bs-actionsbox { - padding: 0 8px 4px; + padding: 0.5rem 0.75rem 0.25rem; } & .form-control { diff --git a/tests/e2e/single-select.spec.js b/tests/e2e/single-select.spec.js index 02e1a5fe..7416b665 100644 --- a/tests/e2e/single-select.spec.js +++ b/tests/e2e/single-select.spec.js @@ -210,6 +210,70 @@ test('menu header renders its close button at the end with compact spacing', asy }); }); +test('dropupAuto keeps the menu below when there is room for controls and one option', async ({ page }) => { + await page.setViewportSize({ width: 900, height: 520 }); + await page.goto('/tests/index.html'); + await page.waitForFunction(() => window.Selectpicker); + + await page.evaluate(() => { + document.body.innerHTML += ` +
+
+ Question pool +
+
+ + +
+
`; + + new Selectpicker('#card-header-example'); + }); + + const picker = page.locator('.bootstrap-select').filter({ has: page.locator('[data-id="card-header-example"]') }); + + await picker.locator('[data-id="card-header-example"]').click(); + + const bodyContainer = page.locator('body > .bs-container'); + + await expect(bodyContainer).toHaveCount(1); + await expect(bodyContainer.locator(':scope > .dropdown-menu')).toBeVisible(); + await expect(picker).not.toHaveClass(/dropup/); + + await expect.poll(async () => page.evaluate(() => { + const buttonRect = document.querySelector('[data-id="card-header-example"]').getBoundingClientRect(); + const menuRect = document.querySelector('body > .bs-container .dropdown-menu').getBoundingClientRect(); + + return menuRect.top >= buttonRect.bottom - 1; + })).toBe(true); + + await expect.poll(async () => page.evaluate(() => { + const search = document.querySelector('body > .bs-container .bs-searchbox'); + const actionsButton = document.querySelector('body > .bs-container .bs-actionsbox .btn'); + + return Math.round(actionsButton.getBoundingClientRect().top - search.getBoundingClientRect().bottom); + })).toBeGreaterThan(0); +}); + test('native bs.select events are emitted on the original select', async ({ page }) => { await page.goto('/tests/index.html'); await page.waitForFunction(() => window.Selectpicker);