Skip to content
Open
42 changes: 35 additions & 7 deletions js/src/menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
execute,
getElement,
getNextActiveElement,
getTransitionDurationFromElement,
isDisabled,
isElement,
isRTL,
Expand Down Expand Up @@ -211,7 +212,15 @@ class Menu extends BaseComponent {
}

Menu._openInstances.add(this)
EventHandler.trigger(this._element, EVENT_SHOWN, relatedTarget)

// Wait for the CSS entry transition (opacity/transform) to finish before
// announcing `shown`, so listeners run against the settled menu.
this._queueCallback(() => {
// Bail if the menu was hidden again mid-transition.
if (this._isShown()) {
EventHandler.trigger(this._element, EVENT_SHOWN, relatedTarget)
}
}, this._menu, this._isAnimated())
}

hide() {
Expand Down Expand Up @@ -275,9 +284,11 @@ class Menu extends BaseComponent {
}
}

this._disposeFloating()
this._restoreMenuToOriginalParent()

// Start the exit transition by removing `.show`. Keep Floating UI active and
// the menu in its current DOM location so it animates out in place; the
// teardown (dispose floating, restore original parent) is deferred until the
// transition ends. Doing it now would move/reposition the menu mid-fade and
// make the exit animation appear to snap.
this._menu.classList.remove(CLASS_NAME_SHOW)
this._element.classList.remove(CLASS_NAME_SHOW)

Expand All @@ -286,10 +297,20 @@ class Menu extends BaseComponent {
}

this._element.setAttribute('aria-expanded', 'false')
Manipulator.removeDataAttribute(this._menu, 'placement')
Manipulator.removeDataAttribute(this._menu, 'display')
Menu._openInstances.delete(this)
EventHandler.trigger(this._element, EVENT_HIDDEN, relatedTarget)

this._queueCallback(() => {
// Bail if the menu was reopened during the exit transition.
if (this._isShown()) {
return
}

this._disposeFloating()
this._restoreMenuToOriginalParent()
Manipulator.removeDataAttribute(this._menu, 'placement')
Manipulator.removeDataAttribute(this._menu, 'display')
EventHandler.trigger(this._element, EVENT_HIDDEN, relatedTarget)
}, this._menu, this._isAnimated())
}

_getConfig(config) {
Expand Down Expand Up @@ -363,6 +384,13 @@ class Menu extends BaseComponent {
return this._menu.classList.contains(CLASS_NAME_SHOW)
}

_isAnimated() {
// The menu always defines its transition in CSS; treat a zero computed
// duration (e.g. reduced motion or disabled transitions) as non-animated so
// callbacks run synchronously.
return getTransitionDurationFromElement(this._menu) > 0
}

_getPlacement() {
const placement = this._responsivePlacements ?
getResponsivePlacement(this._responsivePlacements, DEFAULT_PLACEMENT) :
Expand Down
7 changes: 6 additions & 1 deletion js/src/toggler.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="toggler"]'

const DefaultType = {
attribute: 'string',
value: '(string|number|boolean)'
value: '(string|number|boolean|null)'
}

const Default = {
Expand Down Expand Up @@ -72,6 +72,11 @@ class Toggler extends BaseComponent {
return // You have to be kidding
}

// Nothing to toggle without a value (e.g. missing `data-bs-value`)
if (value === null || value === undefined) {
return
}

if (attribute === 'class') {
this._element.classList.toggle(value)
return
Expand Down
11 changes: 11 additions & 0 deletions js/tests/unit/toggler.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,17 @@ describe('Toggler', () => {
expect(togglerEl.hasAttribute('hidden')).toBeFalse()
})

it('should not throw and should be a no-op when no value is provided', () => {
fixtureEl.innerHTML = '<div data-bs-toggle="toggler" data-bs-attribute="class"></div>'

const togglerEl = fixtureEl.querySelector('[data-bs-toggle="toggler"]')
const toggler = new Toggler(togglerEl)
const classNameBefore = togglerEl.className

expect(() => toggler.toggle()).not.toThrow()
expect(togglerEl.className).toEqual(classNameBefore)
})

it('should not toggle id attribute', () => {
fixtureEl.innerHTML = '<div data-bs-toggle="toggler" data-bs-value="new-id" data-bs-attribute="id"></div>'

Expand Down
2 changes: 1 addition & 1 deletion scss/_accordion.scss
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ $accordion-tokens: defaults(
--accordion-bg: var(--bg-body),
--accordion-transition-property: "color, background-color, border-radius",
--accordion-transition-timing: ".15s ease-in-out",
--accordion-transition: var(--accordion-transition-property) var(--accordion-timing),
--accordion-transition: var(--accordion-transition-property) var(--accordion-transition-timing),
--accordion-border-color: var(--border-color),
--accordion-border-width: var(--border-width),
--accordion-border-radius: var(--accordion-radius, var(--radius-7)),
Expand Down
12 changes: 8 additions & 4 deletions scss/_carousel.scss
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ $carousel-tokens: defaults(
--carousel-indicator-width: .75rem,
--carousel-indicator-height: .75rem,
--carousel-indicator-spacer: .25rem,
--carousel-indicator-transition: "opacity .6s ease, width .3s ease",
--carousel-indicator-opacity-duration: .6s,
--carousel-indicator-opacity-timing: ease,
--carousel-indicator-width-duration: .3s,
--carousel-indicator-width-timing: ease,
--carousel-indicator-progress-bg: var(--carousel-indicator-bg),
--carousel-control-icon-width: 1rem,
--carousel-control-prev-icon: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><path d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0'/></svg>"),
Expand All @@ -34,6 +37,7 @@ $carousel-tokens: defaults(
--carousel-items-gap: 0px,
--carousel-items-peek: 0px,
--carousel-fade-duration: .6s,
--carousel-fade-timing: ease,
),
$carousel-tokens
);
Expand Down Expand Up @@ -122,13 +126,13 @@ $carousel-tokens: defaults(
width: 100%;
visibility: hidden;
opacity: 0;
@include transition(opacity var(--carousel-fade-duration) ease, visibility 0s linear var(--carousel-fade-duration));
@include transition(opacity var(--carousel-fade-duration) var(--carousel-fade-timing), visibility 0s linear var(--carousel-fade-duration));
}

.carousel-item.active {
visibility: visible;
opacity: 1;
@include transition(opacity var(--carousel-fade-duration) ease);
@include transition(opacity var(--carousel-fade-duration) var(--carousel-fade-timing));
}
}

Expand Down Expand Up @@ -201,7 +205,7 @@ $carousel-tokens: defaults(
background-color: transparent;
border: 1px solid var(--carousel-indicator-bg);
@include border-radius(var(--carousel-indicator-width));
@include transition(var(--carousel-indicator-transition));
@include transition(opacity var(--carousel-indicator-opacity-duration) var(--carousel-indicator-opacity-timing), width var(--carousel-indicator-width-duration) var(--carousel-indicator-width-timing));
}

.active {
Expand Down
2 changes: 1 addition & 1 deletion scss/_dialog.scss
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ $dialog-tokens: defaults(
--dialog-border-radius: var(--radius-7),
--dialog-box-shadow: var(--box-shadow-xl),
--dialog-transition-duration: .3s,
--dialog-transition-timing: cubic-bezier(.22, 1, .36, 1),
--dialog-transition-timing: var(--transition-timing-overlay),
--dialog-backdrop-bg: light-dark(rgb(0 0 0 / 50%), rgb(0 0 0 / 65%)),
--dialog-backdrop-blur: 8px,
--dialog-header-padding: 1rem,
Expand Down
2 changes: 1 addition & 1 deletion scss/_drawer.scss
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ $drawer-tokens: defaults(
--drawer-border-radius: var(--radius-7),
--drawer-box-shadow: var(--box-shadow-xl),
--drawer-transition-duration: .3s,
--drawer-transition-timing: cubic-bezier(.22, 1, .36, 1),
--drawer-transition-timing: var(--transition-timing-overlay),
--drawer-title-line-height: 1.5,
--drawer-backdrop-bg: color-mix(in oklch, var(--bg-body) 25%, transparent),
--drawer-backdrop-blur: 8px,
Expand Down
2 changes: 1 addition & 1 deletion scss/_menu.scss
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ $menu-tokens: defaults(
--menu-header-padding-x: .75rem,
--menu-header-padding-y: .25rem,
--menu-transition-duration: .15s,
--menu-transition-timing: cubic-bezier(.22, 1, .36, 1),
--menu-transition-timing: var(--transition-timing-overlay),
),
$menu-tokens
);
Expand Down
1 change: 1 addition & 0 deletions scss/_navbar.scss
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ $navbar-nav-tokens: defaults(
.navbar-toggler {
--btn-bg: transparent;
--btn-hover-bg: var(--bg-2);
@include transition(var(--navbar-toggler-transition));
}

// Hamburger icon, rendered via CSS mask so it inherits the navbar color
Expand Down
9 changes: 9 additions & 0 deletions scss/_root.scss
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ $root-tokens: defaults(
--shadow-strength: 1,
// scss-docs-end root-box-shadow-variables

// scss-docs-start root-transition-variables
// Shared easing curve for overlay components (dialog, drawer, menu)
--transition-timing-overlay: cubic-bezier(.22, 1, .36, 1),
// scss-docs-end root-transition-variables

--spacer: 1rem,

// scss-docs-start root-focus-variables
Expand All @@ -69,6 +74,9 @@ $root-tokens: defaults(
--control-active-border-color: var(--control-active-bg),
--control-disabled-bg: var(--bg-3),
--control-disabled-opacity: .65,
// Shared motion for checkbox/radio marks; slight overshoot so the mark pops in
--control-transition-duration: .15s,
--control-transition-timing: cubic-bezier(.34, 1.56, .64, 1),

--btn-input-fg: var(--fg-body),
--btn-input-bg: var(--bg-body),
Expand All @@ -80,6 +88,7 @@ $root-tokens: defaults(
--btn-input-line-height: var(--body-line-height),
--btn-input-border-radius: var(--radius-5),

--btn-input-xs-gap: var(--spacer-1),
--btn-input-xs-min-height: 1.5rem,
--btn-input-xs-padding-y: .125rem,
--btn-input-xs-padding-x: .5rem,
Expand Down
13 changes: 7 additions & 6 deletions scss/_transitions.scss
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,25 @@ $collapse-tokens: () !default;
// stylelint-disable scss/dollar-variable-default
$fade-tokens: defaults(
(
--transition-fade: opacity .15s linear,
--transition-fade-duration: .15s,
--transition-fade-timing: linear,
),
$fade-tokens
);

// scss-docs-start collapse-transition
$collapse-tokens: defaults(
(
--transition-collapse: height .35s ease,
--transition-collapse-width: width .35s ease,
--transition-collapse-duration: .35s,
--transition-collapse-timing: ease,
),
$collapse-tokens
);
// scss-docs-end collapse-transition

.fade {
@include tokens($fade-tokens);
@include transition(var(--transition-fade));
@include transition(opacity var(--transition-fade-duration) var(--transition-fade-timing));

&:not(.show) {
opacity: 0;
Expand All @@ -43,12 +44,12 @@ $collapse-tokens: defaults(
@include tokens($collapse-tokens);
height: 0;
overflow: hidden;
@include transition(var(--transition-collapse));
@include transition(height var(--transition-collapse-duration) var(--transition-collapse-timing));

&.collapse-horizontal {
width: 0;
height: auto;
@include transition(var(--transition-collapse-width));
@include transition(width var(--transition-collapse-duration) var(--transition-collapse-timing));
}
}
// scss-docs-end collapse-classes
20 changes: 20 additions & 0 deletions scss/_utilities.scss
Original file line number Diff line number Diff line change
Expand Up @@ -999,6 +999,26 @@ $utilities: map.merge(
values: $zindex-levels,
),
// scss-docs-end utils-zindex
// scss-docs-start utils-transition
"transition": (
property: transition,
class: transition,
values: (
none: none,
)
),
// scss-docs-end utils-transition
// scss-docs-start utils-animation
"animation": (
property: animation,
class: animation,
values: (
none: none,
shake: shake .82s cubic-bezier(.36, .07, .19, .97) both,
pop: pop .3s cubic-bezier(.34, 1.56, .64, 1) both,
)
),
// scss-docs-end utils-animation
),
$utilities
);
1 change: 1 addition & 0 deletions scss/buttons/_button.scss
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,7 @@ $btn-variant-selectors: (string.unquote(".btn"), string.unquote(".btn-link"), st
@each $size, $_ in $button-sizes {
.btn-#{$size},
.btn-group-#{$size} > [class*="btn-"] {
--btn-gap: var(--btn-input-#{$size}-gap);
--btn-min-height: var(--btn-input-#{$size}-min-height);
--btn-padding-y: var(--btn-input-#{$size}-padding-y);
--btn-padding-x: var(--btn-input-#{$size}-padding-x);
Expand Down
33 changes: 25 additions & 8 deletions scss/forms/_check.scss
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
@use "../mixins/focus-ring" as *;
@use "../mixins/mask-icon" as *;
@use "../mixins/tokens" as *;
@use "../mixins/transition" as *;

// stylelint-disable custom-property-no-missing-var-function
$check-tokens: () !default;
Expand Down Expand Up @@ -48,21 +49,37 @@ $check-tokens: defaults(
border: 1px solid var(--theme-bg, var(--check-border-color));
// stylelint-disable-next-line property-disallowed-list
border-radius: 33%;
@include transition(
background-color var(--control-transition-duration) var(--control-transition-timing),
border-color var(--control-transition-duration) var(--control-transition-timing)
);

// Check/indeterminate mark, overlaid on the input and rendered via a CSS
// mask so it inherits the contrast color without an inline SVG. Always
// present but scaled/faded out until checked so it can pop in.
&::before {
position: absolute;
inset: 0;
pointer-events: none;
content: "";
background-color: var(--theme-contrast, var(--primary-contrast));
opacity: 0;
transform: scale(0);
@include mask-icon();
@include transition(
opacity var(--control-transition-duration) var(--control-transition-timing),
transform var(--control-transition-duration) var(--control-transition-timing)
);
}

&:checked,
&:indeterminate {
background-color: var(--theme-bg, var(--check-checked-bg));
border-color: var(--theme-bg, var(--check-checked-border-color));

// Check/indeterminate mark, overlaid on the input and rendered via a CSS
// mask so it inherits the contrast color without an inline SVG.
&::before {
position: absolute;
inset: 0;
pointer-events: none;
content: "";
background-color: var(--theme-contrast, var(--primary-contrast));
@include mask-icon();
opacity: 1;
transform: scale(1);
}
}

Expand Down
Loading
Loading