diff --git a/CHANGELOG.md b/CHANGELOG.md index 88d1193c..947a41c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +# Unreleased + +### Changes + +- Added the `btn-theme` button style and made it the default `style`. It renders light in light mode and dark in dark mode by following Bootstrap's CSS variables, which is how the default `btn-light` already behaved. +- `btn-light` now looks exactly like `btn-theme` in light mode but stays light in every color mode, including dark mode. Use `btn-theme` (or omit `style`) to follow the color mode. +- A `btn-dark` toggle now has a visible border in dark mode, matching a native dark-mode input. +- The active (selected) menu item is highlighted with a neutral gray instead of the primary color. Override `--bs-dropdown-link-active-bg` and `--bs-dropdown-link-active-color` on `.bootstrap-select .dropdown-menu` to restore the primary highlight. +- The open menu now has Bootstrap's standard drop shadow (`--bs-box-shadow`). + # v1.1.2 (CrestApps fork) ### Fixes diff --git a/docs/content/examples.mdx b/docs/content/examples.mdx index 09dacb26..b5bc1568 100644 --- a/docs/content/examples.mdx +++ b/docs/content/examples.mdx @@ -682,6 +682,13 @@ You can set the button classes via the `data-style` attribute: +
+ +
+ + diff --git a/docs/content/options.mdx b/docs/content/options.mdx index 4bd1fa57..c207b409 100644 --- a/docs/content/options.mdx +++ b/docs/content/options.mdx @@ -312,10 +312,11 @@ For security reasons, the `sanitize`, `sanitizeFn`, and `whiteList` options cann style string | null - 'btn-light' + 'btn-theme'

When set to a string, add the value to the button's style.

+

btn-theme is provided by bootstrap-select. It renders like a native Bootstrap form control: light in light mode and dark in dark mode, following data-bs-theme. btn-light looks the same as btn-theme in light mode but always stays light, even in dark mode. Other Bootstrap button classes such as btn-dark keep their stock Bootstrap look.

diff --git a/js/bootstrap-select.constants.js b/js/bootstrap-select.constants.js index e24d48f5..483e956b 100644 --- a/js/bootstrap-select.constants.js +++ b/js/bootstrap-select.constants.js @@ -34,7 +34,7 @@ var classNames = { DROPUP: 'dropup', MENU: 'dropdown-menu', MENUEND: 'dropdown-menu-end', - BUTTONCLASS: 'btn-light', + BUTTONCLASS: 'btn-theme', POPOVERHEADER: 'popover-header', ICONBASE: '', TICKICON: 'bs-ok-default' diff --git a/js/bootstrap-select.render.js b/js/bootstrap-select.render.js index 17bdac65..84a8bfb0 100644 --- a/js/bootstrap-select.render.js +++ b/js/bootstrap-select.render.js @@ -438,5 +438,26 @@ if (style) button.classList.remove.apply(button.classList, style.split(' ')); if (buttonClass) button.classList.add.apply(button.classList, buttonClass.split(' ')); } + + this.syncMenuTheme(); + } + + /** + * A `btn-light` button always stays light, so its menu has to stay light too. + * Pin the menu to Bootstrap's light color mode while the button is + * `btn-light`, and release it again when the style changes. + */ + syncMenuTheme () { + var menu = this.menu; + + if (!menu || !this.button) return; + + if (this.button.classList.contains('btn-light')) { + menu.setAttribute('data-bs-theme', 'light'); + menu.setAttribute('data-bs-select-theme-pinned', ''); + } else if (menu.hasAttribute('data-bs-select-theme-pinned')) { + menu.removeAttribute('data-bs-theme'); + menu.removeAttribute('data-bs-select-theme-pinned'); + } } diff --git a/sass/bootstrap-select.scss b/sass/bootstrap-select.scss index c10a8b52..30d4ed27 100644 --- a/sass/bootstrap-select.scss +++ b/sass/bootstrap-select.scss @@ -154,14 +154,13 @@ select.selectpicker { } } -// Make the default toggle button look and behave like a native Bootstrap -// form-control. Its colors are driven entirely by Bootstrap's CSS variables -// (with fallbacks) so the control stays visually identical to inputs in both -// light and dark modes, follows the site's --bs-border-color, and never -// darkens on hover/active the way the stock .btn-light does. Consumers can -// restyle it globally by overriding the Bootstrap variables, and choosing a -// different `style` (e.g. btn-primary) opts out of these defaults entirely. -.bootstrap-select > .dropdown-toggle.btn-light { +// `btn-theme` is the default toggle style. It looks and behaves like a native +// Bootstrap form-control: it renders light in light mode and dark in dark mode +// because its colors are driven entirely by Bootstrap's CSS variables (with +// fallbacks). It follows the site's --bs-border-color and never darkens on +// hover/active the way the stock .btn-light does. Consumers can restyle it +// globally by overriding the Bootstrap variables. +.bootstrap-select > .dropdown-toggle.btn-theme { --bs-btn-color: var(--bs-body-color, #212529); --bs-btn-bg: var(--bs-body-bg, #fff); --bs-btn-border-color: var(--bs-border-color, #dee2e6); @@ -177,6 +176,42 @@ select.selectpicker { --bs-btn-focus-shadow-rgb: var(--bs-primary-rgb, 13, 110, 253); } +// `btn-light` renders exactly like `btn-theme` does in light mode (a white, +// bordered, form-control-like button that does not darken on hover) but stays +// light in dark mode. It uses Bootstrap's fixed palette variables, which do not +// change with `data-bs-theme`, instead of the theme-aware ones above. +.bootstrap-select > .dropdown-toggle.btn-light { + --bs-btn-color: var(--bs-gray-900, #212529); + --bs-btn-bg: var(--bs-white, #fff); + --bs-btn-border-color: var(--bs-gray-300, #dee2e6); + --bs-btn-hover-color: var(--bs-gray-900, #212529); + --bs-btn-hover-bg: var(--bs-white, #fff); + --bs-btn-hover-border-color: var(--bs-gray-300, #dee2e6); + --bs-btn-active-color: var(--bs-gray-900, #212529); + --bs-btn-active-bg: var(--bs-white, #fff); + --bs-btn-active-border-color: var(--bs-gray-300, #dee2e6); + --bs-btn-disabled-color: var(--bs-gray-600, #6c757d); + --bs-btn-disabled-bg: var(--bs-gray-200, #e9ecef); + --bs-btn-disabled-border-color: var(--bs-gray-300, #dee2e6); + --bs-btn-focus-shadow-rgb: var(--bs-primary-rgb, 13, 110, 253); +} + +// Deeper menu shadow on a dark page, where the default one is not visible. A +// menu pinned to the light color mode (a `btn-light` toggle) keeps the light one. +[data-bs-theme="dark"] .bootstrap-select .dropdown-menu:not(.inner):not([data-bs-theme="light"]) { + --bs-select-menu-shadow: 0 0.5rem 1.25rem rgba(var(--bs-black-rgb, 0, 0, 0), 0.65); +} + +// In dark mode a `btn-dark` toggle would otherwise blend into the page because +// its border is the same color as its background. Give it the same visible +// border a native dark-mode input has. +[data-bs-theme="dark"] .bootstrap-select > .dropdown-toggle.btn-dark { + --bs-btn-border-color: var(--bs-border-color, #495057); + --bs-btn-hover-border-color: var(--bs-border-color, #495057); + --bs-btn-active-border-color: var(--bs-border-color, #495057); + --bs-btn-disabled-border-color: var(--bs-border-color, #495057); +} + // The selectpicker components .bootstrap-select { &.form-control { @@ -341,6 +376,23 @@ select.selectpicker { min-width: 100%; box-sizing: border-box; + // Bootstrap highlights the active item with the primary color. Use the same + // neutral gray a native select highlights with instead, driven by Bootstrap + // variables so it works in light and dark modes. Consumers can override + // these variables to restore the primary highlight. + --bs-dropdown-link-hover-color: var(--bs-body-color, #212529); + --bs-dropdown-link-hover-bg: var(--bs-tertiary-bg, #f8f9fa); + --bs-dropdown-link-active-color: var(--bs-body-color, #212529); + --bs-dropdown-link-active-bg: var(--bs-secondary-bg, #e9ecef); + + // The popup floats over the page, so lift it with a drop shadow. The inner + // scroll container is excluded (see `.inner` below). Bootstrap's own shadow + // is a faint black that vanishes on a dark page, so dark mode gets a deeper + // one. Override `--bs-select-menu-shadow` to change it. + &:not(.inner) { + box-shadow: var(--bs-select-menu-shadow, var(--bs-box-shadow, 0 0.5rem 1rem rgba(var(--bs-black-rgb, 0, 0, 0), 0.15))); + } + > .inner:focus { outline: none !important; } @@ -359,7 +411,7 @@ select.selectpicker { position: relative; &.active small { - color: $input-alt-color-placeholder !important; + color: var(--bs-secondary-color, #6c757d) !important; } &.disabled a { @@ -400,7 +452,7 @@ select.selectpicker { background: var(--bs-tertiary-bg, rgb(245, 245, 245)); color: var(--bs-secondary-color, inherit); border: 1px solid var(--bs-border-color, rgb(227, 227, 227)); - box-shadow: inset 0 1px 1px fade(rgb(0, 0, 0), 5%); + box-shadow: inset 0 1px 1px rgba(var(--bs-black-rgb, 0, 0, 0), 0.05); pointer-events: none; opacity: 0.9; box-sizing: border-box; @@ -557,7 +609,7 @@ select.selectpicker { content: ''; border-left: 6px solid transparent; border-right: 6px solid transparent; - border-bottom: 6px solid white; + border-bottom: 6px solid var(--bs-dropdown-bg, var(--bs-body-bg, #fff)); position: absolute; bottom: -4px; left: 10px; @@ -576,7 +628,7 @@ select.selectpicker { &:after { bottom: auto; top: -4px; - border-top: 6px solid white; + border-top: 6px solid var(--bs-dropdown-bg, var(--bs-body-bg, #fff)); border-bottom: 0; } } @@ -660,6 +712,7 @@ select.selectpicker { // Drive the Select All / Deselect All buttons from Bootstrap variables so // they stay legible in dark mode instead of remaining light-on-light. + & .btn-theme, & .btn-light { --bs-btn-color: var(--bs-body-color, #212529); --bs-btn-bg: var(--bs-tertiary-bg, #f8f9fa); @@ -750,7 +803,7 @@ select.selectpicker { font-size: 0.8125rem; line-height: 1.125rem; text-align: left; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + box-shadow: 0 1px 2px rgba(var(--bs-black-rgb, 0, 0, 0), 0.04); transition: background-color .15s ease-in-out, border-color .15s ease-in-out, box-shadow .15s ease-in-out; appearance: none; -webkit-appearance: none; diff --git a/sass/variables.scss b/sass/variables.scss index d2f6cdc8..91196b91 100644 --- a/sass/variables.scss +++ b/sass/variables.scss @@ -1,6 +1,6 @@ $color-red-error: rgb(185, 74, 72) !default; $color-green-success: #28a745 !default; -$color-grey-arrow: rgba(204, 204, 204, 0.2) !default; +$color-grey-arrow: var(--bs-border-color-translucent, rgba(204, 204, 204, 0.2)) !default; $width-default: 100% !default; @@ -11,7 +11,7 @@ $zindex-select-dropdown: 1060 !default; // must be higher than a modal backgroun // (`.form-control`/`.form-select` placeholders resolve to `--bs-secondary-color`) // so the selectpicker's placeholder matches native inputs in light and dark modes. $input-color-placeholder: var(--bs-secondary-color, #6c757d) !default; -$input-alt-color-placeholder: rgba(255, 255, 255, 0.5) !default; +$input-alt-color-placeholder: rgba(var(--bs-white-rgb, 255, 255, 255), 0.5) !default; //** Bootstrap 5.3-style chevron, checkmark, and remove (×) icons. // Rendered through CSS masks so they inherit the current text color and share a diff --git a/tests/e2e/theme-styles.spec.js b/tests/e2e/theme-styles.spec.js new file mode 100644 index 00000000..4eeb8941 --- /dev/null +++ b/tests/e2e/theme-styles.spec.js @@ -0,0 +1,118 @@ +const { test, expect } = require('@playwright/test'); + +// Renders a selectpicker with the given style under the given color mode and +// returns the computed styles that the theming rules are responsible for. +async function render (page, { theme, style }) { + return page.evaluate(({ theme, style }) => { + document.documentElement.setAttribute('data-bs-theme', theme); + + const select = document.createElement('select'); + select.className = 'selectpicker'; + if (style) { select.setAttribute('data-style', style); } + ['One', 'Two', 'Three'].forEach((text) => { + const option = document.createElement('option'); + option.textContent = text; + select.appendChild(option); + }); + select.options[1].selected = true; + document.body.appendChild(select); + + window.Selectpicker.getOrCreateInstance(select); + const wrapper = select.closest('.bootstrap-select') || select.parentElement; + const button = wrapper.querySelector('.dropdown-toggle'); + button.click(); + + const menu = wrapper.querySelector('.dropdown-menu:not(.inner)'); + const active = wrapper.querySelector('.dropdown-menu li.active a, .dropdown-menu li.selected a'); + const css = (el) => (el ? getComputedStyle(el) : null); + + return { + buttonClass: button.className, + buttonBg: css(button).backgroundColor, + buttonBorder: css(button).borderTopColor, + buttonColor: css(button).color, + bodyBg: css(document.body).backgroundColor, + borderColor: css(document.documentElement).getPropertyValue('--bs-border-color').trim(), + menuBg: css(menu).backgroundColor, + menuColor: css(menu).color, + menuShadow: css(menu).boxShadow, + activeBg: active ? css(active).backgroundColor : null, + primary: css(document.documentElement).getPropertyValue('--bs-primary').trim() + }; + }, { theme, style }); +} + +test.describe('Button styles and menu theming', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/tests/index.html'); + await page.waitForFunction(() => window.Selectpicker); + }); + + test('default style is btn-theme', async ({ page }) => { + const result = await render(page, { theme: 'light' }); + expect(result.buttonClass).toContain('btn-theme'); + expect(result.buttonClass).not.toContain('btn-light'); + }); + + test('btn-theme follows the color mode', async ({ page }) => { + const light = await render(page, { theme: 'light', style: 'btn-theme' }); + expect(light.buttonBg).toBe(light.bodyBg); + await page.reload(); + await page.waitForFunction(() => window.Selectpicker); + const dark = await render(page, { theme: 'dark', style: 'btn-theme' }); + expect(dark.buttonBg).toBe(dark.bodyBg); + expect(dark.buttonBg).not.toBe(light.buttonBg); + }); + + test('btn-light stays light in dark mode', async ({ page }) => { + const dark = await render(page, { theme: 'dark', style: 'btn-light' }); + expect(dark.buttonBg).toBe('rgb(255, 255, 255)'); + expect(dark.buttonBg).not.toBe(dark.bodyBg); + }); + + test('btn-light menu stays light in dark mode', async ({ page }) => { + const dark = await render(page, { theme: 'dark', style: 'btn-light' }); + expect(dark.menuBg).toBe('rgb(255, 255, 255)'); + expect(dark.activeBg).toBe('rgb(233, 236, 239)'); + }); + + test('btn-theme menu follows dark mode', async ({ page }) => { + const dark = await render(page, { theme: 'dark', style: 'btn-theme' }); + expect(dark.menuBg).not.toBe('rgb(255, 255, 255)'); + }); + + test('btn-light looks exactly like btn-theme in light mode', async ({ page }) => { + const theme = await render(page, { theme: 'light', style: 'btn-theme' }); + await page.reload(); + await page.waitForFunction(() => window.Selectpicker); + const light = await render(page, { theme: 'light', style: 'btn-light' }); + expect(light.buttonBg).toBe(theme.buttonBg); + expect(light.buttonBorder).toBe(theme.buttonBorder); + expect(light.buttonColor).toBe(theme.buttonColor); + }); + + test('btn-dark has a visible border in dark mode', async ({ page }) => { + const dark = await render(page, { theme: 'dark', style: 'btn-dark' }); + expect(dark.buttonBorder).not.toBe(dark.buttonBg); + }); + + test('open menu has a drop shadow', async ({ page }) => { + const result = await render(page, { theme: 'light' }); + expect(result.menuShadow).not.toBe('none'); + }); + + test('dark mode menu shadow is deeper than the light one', async ({ page }) => { + const light = await render(page, { theme: 'light' }); + await page.reload(); + await page.waitForFunction(() => window.Selectpicker); + const dark = await render(page, { theme: 'dark' }); + expect(dark.menuShadow).not.toBe('none'); + expect(dark.menuShadow).not.toBe(light.menuShadow); + expect(dark.menuShadow).toContain('0.65'); + }); + + test('active item is gray, not the primary color', async ({ page }) => { + const result = await render(page, { theme: 'light' }); + expect(result.activeBg).toBe('rgb(233, 236, 239)'); + }); +});