diff --git a/js/src/carousel.js b/js/src/carousel.js index 057ef86720..bd6f81cf55 100644 --- a/js/src/carousel.js +++ b/js/src/carousel.js @@ -82,6 +82,8 @@ const CLASS_NAME_START = 'carousel-item-start' const CLASS_NAME_NEXT = 'carousel-item-next' const CLASS_NAME_PREV = 'carousel-item-prev' const CLASS_NAME_POINTER_EVENT = 'pointer-event' +const CLASS_NAME_PAUSED = 'paused' // Boosted mod: used for progress indicators +const CLASS_NAME_DONE = 'done' // Boosted mod: used for progress indicators const SELECTOR_ACTIVE = '.active' const SELECTOR_ACTIVE_ITEM = '.active.carousel-item' @@ -91,6 +93,8 @@ const SELECTOR_NEXT_PREV = '.carousel-item-next, .carousel-item-prev' const SELECTOR_INDICATORS = '.carousel-indicators' const SELECTOR_DATA_SLIDE = '[data-bs-slide], [data-bs-slide-to]' const SELECTOR_DATA_RIDE = '[data-bs-ride="carousel"]' +const SELECTOR_CONTROL_PREV = '.carousel-control-prev' // Boosted mod +const SELECTOR_CONTROL_NEXT = '.carousel-control-next' // Boosted mod const PREFIX_CUSTOM_PROPS = 'o-' // Boosted mod: should match `$boosted-variable-prefix` in scss/_variables.scss @@ -158,6 +162,12 @@ class Carousel extends BaseComponent { } pause(event) { + // Boosted mod: reset the animation on progress indicator + if (this._indicatorsElement) { + this._element.classList.add(CLASS_NAME_PAUSED) + } + // End mod + if (!event) { this._isPaused = true } @@ -172,6 +182,12 @@ class Carousel extends BaseComponent { } cycle(event) { + // Boosted mod: restart the animation on progress indicator + if (this._indicatorsElement) { + this._element.classList.remove(CLASS_NAME_PAUSED) + } + // End mod + if (!event) { this._isPaused = false } @@ -195,6 +211,12 @@ class Carousel extends BaseComponent { this._activeElement = SelectorEngine.findOne(SELECTOR_ACTIVE_ITEM, this._element) const activeIndex = this._getItemIndex(this._activeElement) + // Boosted mod: restart the animation on progress indicator + if (this._indicatorsElement) { + this._element.classList.remove(CLASS_NAME_DONE) + } + // End mod + if (index > this._items.length - 1 || index < 0) { return } @@ -350,6 +372,26 @@ class Carousel extends BaseComponent { } } + // Boosted mod: handle prev/next controls states + _disableControl(element) { + if (element.nodeName === 'BUTTON') { + element.disabled = true + } else { + element.setAttribute('aria-disabled', true) + element.setAttribute('tabindex', '-1') + } + } + + _enableControl(element) { + if (element.nodeName === 'BUTTON') { + element.disabled = false + } else { + element.removeAttribute('aria-disabled') + element.removeAttribute('tabindex') + } + } + // End mod + _getItemIndex(element) { this._items = element && element.parentNode ? SelectorEngine.find(SELECTOR_ITEM, element.parentNode) : @@ -366,9 +408,23 @@ class Carousel extends BaseComponent { const isGoingToWrap = (isPrevDirection && activeIndex === 0) || (isNextDirection && activeIndex === lastItemIndex) - if (isGoingToWrap && !this._config.wrap) { - return activeElement + // Boosted mod: progress indicators animation when wrapping is disabled + if (!this._config.wrap) { + if (isGoingToWrap) { + // Reset the animation on last progress indicator when last slide is active + if (isNextDirection && this._indicatorsElement && !this._element.hasAttribute('data-bs-slide')) { + this._element.classList.add(CLASS_NAME_DONE) + } + + return activeElement + } + + // Restart animation otherwise + if (this._indicatorsElement) { + this._element.classList.remove(CLASS_NAME_DONE) + } } + // End mod const delta = direction === DIRECTION_PREV ? -1 : 1 const itemIndex = (activeIndex + delta) % this._items.length @@ -479,6 +535,22 @@ class Carousel extends BaseComponent { this._setActiveIndicatorElement(nextElement) this._activeElement = nextElement + // Boosted mod: enable/disable prev/next controls when wrap=false + if (!this._config.wrap) { + const prevControl = SelectorEngine.findOne(SELECTOR_CONTROL_PREV, this._element) + const nextControl = SelectorEngine.findOne(SELECTOR_CONTROL_NEXT, this._element) + + this._enableControl(prevControl) + this._enableControl(nextControl) + + if (nextElementIndex === 0) { + this._disableControl(prevControl) + } else if (nextElementIndex === (this._items.length - 1)) { + this._disableControl(nextControl) + } + } + // End mod + if (this._element.classList.contains(CLASS_NAME_SLIDE)) { nextElement.classList.add(orderClassName) diff --git a/scss/_carousel.scss b/scss/_carousel.scss index e0db99fb6a..87d3072339 100644 --- a/scss/_carousel.scss +++ b/scss/_carousel.scss @@ -13,7 +13,6 @@ .carousel { position: relative; - margin-bottom: $carousel-margin-bottom; // Boosted mod } .carousel.pointer-event { @@ -114,11 +113,11 @@ } .carousel-control-prev { - left: $carousel-control-offset; // Boosted mod + left: 0; background-image: if($enable-gradients, linear-gradient(90deg, rgba($black, .25), rgba($black, .001)), null); } .carousel-control-next { - right: $carousel-control-offset; // Boosted mod + right: 0; background-image: if($enable-gradients, linear-gradient(270deg, rgba($black, .25), rgba($black, .001)), null); } @@ -128,7 +127,8 @@ display: inline-block; width: $carousel-control-icon-width; height: $carousel-control-icon-width; - background: escape-svg($carousel-control-prev-icon-bg) no-repeat 50% / #{$carousel-control-icon-size $carousel-control-icon-size}; // Boosted mod + background: rgba($carousel-indicator-active-bg, .5) escape-svg($carousel-control-icon-bg) no-repeat subtract(50%, $spacer / 10) 50% / #{$carousel-control-icon-size $carousel-control-icon-size}; // Boosted mod + @include border-radius(50%, 50%); } // Boosted mod @@ -145,6 +145,34 @@ transform: scaleX(-1); } */ + +@each $direction in "prev", "next" { + .carousel-control-#{$direction} { + &:hover, + &:focus { + .carousel-control-#{$direction}-icon { + background-color: $carousel-indicator-active-bg; + filter: $carousel-control-icon-filter; + } + } + + &:active { + .carousel-control-#{$direction}-icon { + background-color: $carousel-control-icon-active-bg; + filter: none; + } + } + + &:disabled, + &[aria-disabled] { + pointer-events: none; + + .carousel-control-#{$direction}-icon { + background-image: escape-svg($carousel-control-icon-disabled-bg); + } + } + } +} // End mod // Optional indicator pips @@ -152,19 +180,20 @@ // Add an ordered list with the following class and add a list item for each // slide your carousel holds. +/* rtl:begin:ignore */ .carousel-indicators { position: absolute; - right: 0; - bottom: -$carousel-margin-bottom; - left: 0; + bottom: 0; + left: 50%; // Boosted mod z-index: 2; display: flex; justify-content: center; - padding-left: 0; // override
    default - // Use the .carousel-control's width as margin so we don't overlay those - margin-right: $carousel-control-width; - margin-left: $carousel-control-width; + padding: $carousel-indicators-padding-y 0; // Boosted mod + // Boosted mod: no margins list-style: none; + background: rgba($carousel-indicator-active-bg, .5); // Boosted mod + transform: translateX(-50%); // Boosted mod + @include border-radius($spacer, $spacer); // Boosted mod li { box-sizing: content-box; @@ -176,21 +205,96 @@ text-indent: -999px; cursor: pointer; background-color: $carousel-control-color; // Boosted mod - background-clip: padding-box; - // Use transparent borders to increase the hit area by 10px on top and bottom. - border-top: $carousel-indicator-hit-area-height solid transparent; - border-bottom: $carousel-indicator-hit-area-height solid transparent; + // Boosted mod: use our own target-size() mixin instead of transparent borders opacity: $carousel-indicator-opacity; @include transition($carousel-indicator-transition); - @include border-radius(50%, 50%); // Boosted mod + + // Boosted mod + @include border-radius(50%, 50%); + @include target-size($carousel-indicator-hit-area-height); + + &:hover, + &:focus { + background-color: color-contrast($carousel-indicator-active-bg); + transform: scale($carousel-indicator-hover-scale); + + &::before { + transform: translate3d(-50%, -50%, 0) scale($carousel-indicator-active-scale); + } + } } .active { - background-color: $carousel-indicator-active-bg; // Boosted mod + background-color: $carousel-indicator-active-bg; + border-color: color-contrast($carousel-indicator-active-bg); opacity: $carousel-indicator-active-opacity; + + @if $enable-transitions { + // Animation based on Lea Verou's simple pie chart + // See https://www.smashingmagazine.com/2015/07/designing-simple-pie-charts-with-css/ + background-image: linear-gradient(to right, transparent 50%, color-contrast($carousel-indicator-active-bg) 0); + transform: scale($carousel-indicator-hover-scale); + // No need to feature test mask-* + // See https://caniuse.com/mdn-css_properties_mask-image + mask-image: radial-gradient(circle at 50%, transparent 33%, $white add(33%, 1px)); + + &:hover, + &:focus { + mask-image: none; + } + + &::before { + transform: translate3d(-50%, -50%, 0) scale($carousel-indicator-active-scale); + } + + &::after { + position: absolute; + top: 0; + left: 50%; + width: 50%; + height: 100%; + content: ""; + background-color: inherit; + transform-origin: left; + // stylelint-disable-next-line function-disallowed-list + animation: carousel-progress calc(#{$carousel-indicator-animation-interval} / 2) linear infinite, carousel-progress-half $carousel-indicator-animation-interval step-end infinite; + @include border-radius($carousel-indicator-active-radius, $carousel-indicator-active-radius); + } + + @keyframes carousel-progress { + to { transform: rotate(.5turn); } + } + + @keyframes carousel-progress-half { + 50% { background: color-contrast($carousel-indicator-active-bg); } + } + + .carousel.done &, + .carousel.paused &, + .carousel.static & { + background: color-contrast($carousel-indicator-active-bg); + + &::after { + animation: none; + } + } + + @if $enable-reduced-motion { + @media (prefers-reduced-motion: reduce) { + background: color-contrast($carousel-indicator-active-bg); + + &::after { + animation: none; + } + } + } + } } } +/* rtl:end:ignore */ +// End mod + // Optional captions // @@ -198,26 +302,12 @@ .carousel-caption { position: absolute; + right: (100% - $carousel-caption-width) / 2; bottom: $carousel-caption-spacer; - left: map-get($spacers, 5); - padding: $carousel-caption-padding-y; // Boosted mod + left: (100% - $carousel-caption-width) / 2; + padding: $carousel-caption-padding-y $carousel-caption-padding-x; // Boosted mod color: $carousel-caption-color; - background-color: color-contrast($carousel-caption-color); // Boosted mod + background-color: color-contrast($carousel-caption-color); // Boosted mod: ensure contrast } -// Dark mode carousel - -.carousel-dark { - .carousel-control-prev-icon, - .carousel-control-next-icon { - filter: $carousel-dark-control-icon-filter; - } - - .carousel-indicators li { - background-color: $carousel-dark-indicator-active-bg; - } - - .carousel-caption { - color: $carousel-dark-caption-color; - } -} +// Boosted mod: no dark carousel diff --git a/scss/_variables.scss b/scss/_variables.scss index 071b5241c1..cb79e1edfc 100644 --- a/scss/_variables.scss +++ b/scss/_variables.scss @@ -237,7 +237,7 @@ $escaped-characters: ( // Boosted mod $chevron-icon: url("data:image/svg+xml,") !default; -$chevron-icon-disabled: url("data:image/svg+xml,") !default; +$chevron-icon-disabled: url("data:image/svg+xml,") !default; $cross-icon: url("data:image/svg+xml,") !default; $cross-icon-stroke: url("data:image/svg+xml,") !default; $success-icon: url("data:image/svg+xml,") !default; @@ -1355,20 +1355,18 @@ $breadcrumb-border-radius: null !default; // Carousel -$carousel-margin-bottom: $spacer * 3 !default; // Boosted mod $carousel-control-color: $black !default; -$carousel-control-width: $spacer * 1.5 !default; +$carousel-control-width: $spacer * 3 !default; $carousel-control-opacity: null !default; $carousel-control-hover-opacity: null !default; $carousel-control-transition: $transition-focus !default; -$carousel-control-offset: $spacer / 2 !default; // Boosted mod -$carousel-indicator-width: $spacer / 2 !default; -$carousel-indicator-height: $spacer / 2 !default; -$carousel-indicator-hit-area-height: null !default; -$carousel-indicator-spacer: 3px !default; +$carousel-indicator-width: .5rem !default; +$carousel-indicator-height: .5rem !default; +$carousel-indicator-hit-area-height: $spacer * 1.5 !default; +$carousel-indicator-spacer: $spacer / 2 !default; $carousel-indicator-opacity: null !default; -$carousel-indicator-active-bg: $primary !default; +$carousel-indicator-active-bg: $white !default; $carousel-indicator-active-opacity: null !default; $carousel-indicator-transition: null !default; // Boosted mod @@ -1376,24 +1374,30 @@ $carousel-indicator-hover-scale: 1.5 !default; $carousel-indicator-active-scale: calc(2 / 3) !default; // stylelint-disable-line function-disallowed-list $carousel-indicator-active-radius: 0 100% 100% 0 / 50% !default; $carousel-indicator-animation-duration: 5000ms !default; -$carousel-indicator-animation-interval: var(--#{$boosted-variable-prefix}carousel-interval, #{$carousel-indicator-animation-duration}) !default; +$carousel-indicator-animation-interval: var(--carousel-interval, #{$carousel-indicator-animation-duration}) !default; +$carousel-indicators-padding-y: $spacer / 2 !default; // End mod +$carousel-caption-width: 70% !default; $carousel-caption-color: $black !default; $carousel-caption-padding-y: $spacer !default; -$carousel-caption-spacer: 0 !default; +$carousel-caption-padding-x: $spacer !default; // Boosted mod +$carousel-caption-spacer: $spacer * 3 !default; -$carousel-control-icon-width: 2.375rem !default; -$carousel-control-icon-size: $spacer * 1.5 !default; // Boosted mod - -$carousel-control-prev-icon-bg: $chevron-icon !default; +$carousel-control-icon-width: 2.5rem !default; +// Boosted mod +$carousel-control-icon-size: 1.5rem !default; +$carousel-control-icon-active-bg: $component-active-bg !default; +$carousel-control-icon-bg: $chevron-icon !default; +$carousel-control-icon-disabled-bg: $chevron-icon-disabled !default; +$carousel-control-icon-filter: $invert-filter !default; +// End mod $carousel-transition-duration: .6s !default; $carousel-transition: transform $carousel-transition-duration $transition-timing !default; // Define transform transition first if using multiple transitions (e.g., `transform 2s ease, opacity .5s ease-out`) -$carousel-dark-indicator-active-bg: $black !default; -$carousel-dark-caption-color: $black !default; -$carousel-dark-control-icon-filter: invert(1) grayscale(100) !default; +// Boosted mod: no dark carousel + // Spinners diff --git a/site/assets/scss/_component-examples.scss b/site/assets/scss/_component-examples.scss index e1edc7c57e..62500fc3bc 100644 --- a/site/assets/scss/_component-examples.scss +++ b/site/assets/scss/_component-examples.scss @@ -113,12 +113,6 @@ margin-bottom: 0; } - // Boosted mod - > .carousel:last-child { - margin-bottom: $carousel-margin-bottom; - } - // End mod - // Images > svg + svg, > img + img { diff --git a/site/content/docs/5.0/components/carousel.md b/site/content/docs/5.0/components/carousel.md index de31a13109..98c188278c 100644 --- a/site/content/docs/5.0/components/carousel.md +++ b/site/content/docs/5.0/components/carousel.md @@ -162,11 +162,18 @@ Add `data-bs-interval=""` to a `.carousel-item` to change the amount of time to {{< example >}}