From b193830526399d56eed9c8d798fd7ecb5ecf4b45 Mon Sep 17 00:00:00 2001 From: Mark Otto Date: Thu, 23 Apr 2026 13:38:40 -0700 Subject: [PATCH] v6: Dialog fixes (#42353) * Dont auto open toast in overlays dialog example * switch slide up and down * dialog animation improvements * Reflect changes in dialog on Drawer * Don't let dialogs run into viewport edge on mobile * Improve dialog swapping * Fix while in here * remove .dialog-overflow hackery * fix bundlewatch --- .bundlewatch.config.json | 4 +- js/src/dialog-base.js | 30 +++++- js/src/dialog.js | 38 +++++++ scss/_dialog.scss | 108 +++++++++----------- site/src/assets/partials/snippets.js | 7 ++ site/src/content/docs/components/dialog.mdx | 49 ++------- site/src/content/docs/components/drawer.mdx | 14 ++- site/src/content/docs/guides/migration.mdx | 2 +- site/src/scss/_ads.scss | 8 +- 9 files changed, 146 insertions(+), 114 deletions(-) diff --git a/.bundlewatch.config.json b/.bundlewatch.config.json index 3269e7ccbf..3a39bdd910 100644 --- a/.bundlewatch.config.json +++ b/.bundlewatch.config.json @@ -34,7 +34,7 @@ }, { "path": "./dist/js/bootstrap.bundle.js", - "maxSize": "72.5 kB" + "maxSize": "72.75 kB" }, { "path": "./dist/js/bootstrap.bundle.min.js", @@ -42,7 +42,7 @@ }, { "path": "./dist/js/bootstrap.js", - "maxSize": "43.5 kB" + "maxSize": "44.0 kB" }, { "path": "./dist/js/bootstrap.min.js", diff --git a/js/src/dialog-base.js b/js/src/dialog-base.js index 3716878db7..29c6627831 100644 --- a/js/src/dialog-base.js +++ b/js/src/dialog-base.js @@ -98,10 +98,17 @@ class DialogBase extends BaseComponent { this._isTransitioning = true this._hideElement() - this._onAfterHide() this._queueCallback(() => { + // For subclasses that defer close() until the exit transition ends + // (so the dialog stays in the top layer with its ::backdrop), close() + // happens here instead of in _hideElement(). + if (this._element.open) { + this._closeAndCleanup() + } + this._element.classList.remove('hiding') + this._onAfterHide() this._isTransitioning = false EventHandler.trigger( this._element, @@ -163,6 +170,20 @@ class DialogBase extends BaseComponent { // Without this, the navbar's `:not([open])` transition-kill rule // would prevent the slide-out animation. this._element.classList.add('hiding') + + // Subclasses can defer close() until after the exit transition by + // returning true from _shouldDeferClose(). This is needed for the + // native modal centered case: close() removes the dialog + // from the top layer immediately, which strips its auto-centering + // and the ::backdrop, breaking the exit animation. + if (!this._shouldDeferClose()) { + this._closeAndCleanup() + } + } + + // Closes the native and tears down body-scroll prevention. + // Safe to call multiple times — close() is a no-op on a closed dialog. + _closeAndCleanup() { this._element.close() this._openedAsModal = false @@ -172,6 +193,13 @@ class DialogBase extends BaseComponent { } } + // Hook: return true to keep the dialog in the top layer (i.e., delay + // calling close()) until the exit transition completes. The base class + // closes synchronously; Dialog overrides this for animated modal cases. + _shouldDeferClose() { + return false + } + _triggerBackdropTransition() { const hidePreventedEvent = EventHandler.trigger( this._element, diff --git a/js/src/dialog.js b/js/src/dialog.js index f0eb4ea835..c87c814455 100644 --- a/js/src/dialog.js +++ b/js/src/dialog.js @@ -27,6 +27,8 @@ const EVENT_CANCEL = `cancel${EVENT_KEY}` const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}` const CLASS_NAME_NONMODAL = 'dialog-nonmodal' +const CLASS_NAME_INSTANT = 'dialog-instant' +const CLASS_NAME_SWAP_IN = 'dialog-swap-in' const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="dialog"]' @@ -84,6 +86,16 @@ class Dialog extends DialogBase { this._element.classList.remove(CLASS_NAME_NONMODAL) } + // Keep the dialog in the top layer until the exit transition ends. This + // preserves the browser's modal centering and the native ::backdrop, both + // of which disappear synchronously the moment close() is called. Without + // this, the dialog would jump to the top of the page and the backdrop + // blur would vanish instantly while the dialog faded — making the exit + // animation appear to skip entirely. + _shouldDeferClose() { + return this._isAnimated() + } + _onCancel() { EventHandler.trigger(this._element, EVENT_CANCEL) } @@ -120,11 +132,37 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function ( const shouldSwap = currentDialog && currentDialog !== target if (shouldSwap) { + // Swap strategy (seamless backdrop, no flash): + // 1. Mark the incoming dialog with .dialog-swap-in so its ::backdrop + // skips the @starting-style fade-in and appears fully opaque on + // its very first frame in the top layer. + // 2. Open the incoming dialog (showModal). + // 3. Close the outgoing dialog synchronously — no exit transition, no + // .hiding — so its ::backdrop is removed in the same frame the + // incoming dialog's backdrop appears. Since both backdrops render + // the same color, the user sees one continuous backdrop. Two + // simultaneously-visible backdrops would composite to ~75% darker, + // and a fading-out + fading-in pair would dip to ~75% opacity — + // either would look like a flash. + // 4. Clean up the .dialog-swap-in flag once the incoming dialog + // finishes its entry transition. const newDialog = Dialog.getOrCreateInstance(target, config) + target.classList.add(CLASS_NAME_SWAP_IN) newDialog.show(this) + EventHandler.one(target, `shown${EVENT_KEY}`, () => { + target.classList.remove(CLASS_NAME_SWAP_IN) + }) const currentInstance = Dialog.getInstance(currentDialog) if (currentInstance) { + // Force synchronous close: .dialog-instant makes _isAnimated() false, + // which makes _shouldDeferClose() false, so hide() calls close() + // immediately (no deferred .hiding path). The class is removed after + // the (now-synchronous) hidden event fires. + currentDialog.classList.add(CLASS_NAME_INSTANT) + EventHandler.one(currentDialog, EVENT_HIDDEN, () => { + currentDialog.classList.remove(CLASS_NAME_INSTANT) + }) currentInstance.hide() } diff --git a/scss/_dialog.scss b/scss/_dialog.scss index 3e491f822e..b67288c94a 100644 --- a/scss/_dialog.scss +++ b/scss/_dialog.scss @@ -73,7 +73,7 @@ $dialog-sizes: defaults( display: flex; flex-direction: column; width: var(--dialog-width); - max-width: 100%; + max-width: calc(100% - var(--dialog-margin) * 2); max-height: calc(100% - var(--dialog-margin) * 2); padding: 0; margin: auto; @@ -100,20 +100,28 @@ $dialog-sizes: defaults( visibility 0s var(--dialog-transition-duration) ); - // Slide-down variant: enters from above, exits below. + // Slide-down variant: enters from above sliding down, exits by reversing + // back up. Base value is the entry-from / exit-to position so the + // animation works on every open (not just the first, which is the only + // time @starting-style applies for a persistent element). &.dialog-slide-down { - transform: translateY(3rem); + transform: translateY(-3rem); } - // Slide-up variant: enters from below, exits above. + // Slide-up variant: enters from below sliding up, exits by reversing + // back down. See note above re: base value choice. &.dialog-slide-up { - transform: translateY(-3rem); + transform: translateY(3rem); } // Open state: visible and faded in. // Entry transition: visibility flips visible immediately (0s, no delay), // then opacity and transform animate in. - &[open] { + // The :not(.hiding) qualifier lets the exit transition fall back to the + // base "exit" state above while [open] is still present (the JS keeps + // the dialog in the top layer during the exit so the ::backdrop and + // the browser's modal centering remain intact). + &[open]:not(.hiding) { overflow: visible; visibility: visible; opacity: 1; @@ -125,8 +133,12 @@ $dialog-sizes: defaults( transform: none; } - // Static backdrop "bounce" animation (modal dialogs only) - &.dialog-static { + // Static backdrop "bounce" animation (modal dialogs only). Qualified + // with [open] (to outrank the open-state `transform: none` selector + // which now also includes `:not(.hiding)`) and `:not(.hiding)` (so + // a backdrop click while the dialog is mid-exit doesn't fight the + // slide-out transform). + &[open].dialog-static:not(.hiding) { transform: scale(1.02); } @@ -136,6 +148,14 @@ $dialog-sizes: defaults( backdrop-filter: blur(var(--dialog-backdrop-blur)); @include backdrop-transitions(var(--dialog-transition-duration), var(--dialog-transition-timing)); } + + // Exit: fade the native backdrop out alongside the dialog. The dialog + // is kept in the top layer (and thus the ::backdrop is still rendered) + // for the duration of the exit transition. + &.hiding::backdrop { + background-color: transparent; + backdrop-filter: blur(0); + } } // Instant variant — no transitions, just snap visibility @@ -146,8 +166,11 @@ $dialog-sizes: defaults( } } - // Open state base (always applies, regardless of animation mode) - &[open] { + // Open state base (always applies, regardless of animation mode). + // Excluded while .hiding is present so the animated exit (above) can + // fall through to the base "exit" state — for instant dialogs, .hiding + // is removed synchronously after close() so this still applies normally. + &[open]:not(.hiding) { overflow: visible; visibility: visible; opacity: 1; @@ -165,37 +188,6 @@ $dialog-sizes: defaults( transform: translate(-50%, -50%); } - // Overflow dialog - scrollable viewport container with dialog box inside - &.dialog-overflow { - // Make dialog element the full-viewport scrollable container - position: fixed; - inset: 0; - width: 100%; - max-width: 100%; - height: 100%; - max-height: 100%; - padding: var(--dialog-margin); - margin: 0; - overflow-y: auto; - overscroll-behavior: contain; - background: transparent; - border: 0; - box-shadow: none; - - // The visual dialog box is a child wrapper - > .dialog-box { - max-width: var(--dialog-width); - margin-block-end: var(--dialog-margin); - margin-inline: auto; - color: var(--dialog-color); - background-color: var(--dialog-bg); - background-clip: padding-box; - border: var(--dialog-border-width) solid var(--dialog-border-color); - @include border-radius(var(--dialog-border-radius)); - @include box-shadow(var(--dialog-box-shadow)); - } - } - // Scrollable dialog body (header/footer stay fixed) &.dialog-scrollable[open] { max-height: calc(100% - var(--dialog-margin) * 2); @@ -206,29 +198,27 @@ $dialog-sizes: defaults( } } - // Entry animations via @starting-style. - // Slide variants need this because the base transform is the EXIT position, - // but entry must start from the opposite direction. - // ::backdrop needs it since it only exists in the top layer. - // Default dialog (fade only) does NOT need @starting-style — the base - // opacity: 0 state serves as the entry-from state with visibility trick. + // Entry animation for ::backdrop via @starting-style. The backdrop only + // exists while the dialog is in the top layer, so its starting state can't + // be expressed on the base selector. + // Default dialog (fade only) and the slide variants do NOT need + // @starting-style — the base opacity: 0 (and base transform for slides) + // serves as the entry-from state with the visibility trick. @starting-style { - // Slide-down: enters from above (negative Y), slides down into view - .dialog:not(.dialog-instant).dialog-slide-down[open] { - opacity: 0; - transform: translateY(-3rem); - } - - // Slide-up: enters from below (positive Y), slides up into view - .dialog:not(.dialog-instant).dialog-slide-up[open] { - opacity: 0; - transform: translateY(3rem); - } - .dialog:not(.dialog-instant)::backdrop { background-color: transparent; backdrop-filter: blur(0); } + + // Swap entry: when this dialog is opened as the target of a swap, the + // outgoing dialog's ::backdrop is being removed synchronously in the same + // JS tick. To avoid any flicker (either a dip from a fade-in over nothing, + // or double-darkening from two stacked backdrops), start this backdrop + // already-opaque so it takes over from the outgoing one seamlessly. + .dialog.dialog-swap-in:not(.dialog-instant)::backdrop { + background-color: var(--dialog-backdrop-bg); + backdrop-filter: blur(var(--dialog-backdrop-blur)); + } } // Dialog sizes diff --git a/site/src/assets/partials/snippets.js b/site/src/assets/partials/snippets.js index 68f965da51..2e7fa5e275 100644 --- a/site/src/assets/partials/snippets.js +++ b/site/src/assets/partials/snippets.js @@ -53,8 +53,15 @@ export default () => { } // Instantiate all toasts in docs pages only + // Skip toasts inside elements; those are shown explicitly + // via their own trigger (e.g. the "Show toast" button in the dialog + // overlays example) and shouldn't auto-appear when the dialog opens. document.querySelectorAll('.bd-example .toast') .forEach(toastNode => { + if (toastNode.closest('dialog')) { + return + } + const toast = new Toast(toastNode, { autohide: false }) diff --git a/site/src/content/docs/components/dialog.mdx b/site/src/content/docs/components/dialog.mdx index 2272efd3b3..062b146778 100644 --- a/site/src/content/docs/components/dialog.mdx +++ b/site/src/content/docs/components/dialog.mdx @@ -15,13 +15,11 @@ The Dialog component leverages the browser's native `` element, providin Key features of the native dialog: -- **Native modal behavior** via `showModal()` with automatic focus trapping -- **Built-in backdrop** using the `::backdrop` pseudo-element -- **Escape key handling** closes the dialog by default -- **Accessibility** with proper focus management and ARIA attributes -- **Top layer rendering** ensures the dialog appears above all other content - -Native `` elements support two methods: `show()` opens the dialog inline without a backdrop or focus trapping, while `showModal()` opens it as a true modal in the browser's top layer with a backdrop, focus trapping, and Escape key handling. Bootstrap's Dialog component uses `showModal()` to provide the expected modal experience. +- **Modal or inline** via `showModal()` / `show()` — `modal: true` (default) promotes the dialog to the browser's top layer with a backdrop and focus trapping; `modal: false` renders it inline. +- **Built-in backdrop** using the `::backdrop` pseudo-element (modal only); set `backdrop: "static"` to lock clicks outside, or `backdrop: false` to hide it. +- **Escape key handling** closes the dialog by default; set `keyboard: false` to disable. +- **Accessibility** — focus is trapped inside modal dialogs and returned to the trigger on close, with native `` ARIA semantics. +- **Animated open and close** — circumvent browser restrictions by using a `.hiding` class to keep dialogs in the top layer during close so the exit transition (including `::backdrop`) are animated properly. @@ -216,11 +214,9 @@ Add `.dialog-slide-up` to the `` and it will slide **up** from the botto `} /> -## Scrolling - -### Inner scroll +## Scrollable -You can also create a scrollable dialog that scrolls the dialog body while keeping the header and footer fixed. Add `.dialog-scrollable` to the `.dialog` element. +Create a scrollable dialog that scrolls the dialog body while keeping the header and footer fixed. Add `.dialog-scrollable` to the `.dialog` element. Launch scrollable body dialog @@ -247,37 +243,6 @@ You can also create a scrollable dialog that scrolls the dialog body while keepi `} /> -### Overflow scroll - -For a dialog that extends beyond the viewport and scrolls as a whole, add `.dialog-overflow` and wrap the content in a `.dialog-box` element. The `` becomes a full-viewport scrollable container, and the `.dialog-box` is the visual dialog that scrolls up and down. - - - Launch overflow dialog - - - -
-
-

Overflow dialog

- -
-
-

This dialog extends beyond the viewport height. Scroll to see more content. Notice how the entire dialog—including header and footer—shifts up and down as you scroll.

-

The .dialog-overflow modifier creates a full-viewport scrollable container. The .dialog-box wrapper contains the visual dialog that moves up and down as you scroll. This pattern is useful for very long content like terms of service or detailed forms.

-

Unlike the default scrolling behavior where the dialog is constrained to the viewport, overflow dialogs can extend beyond it. This gives users a more document-like reading experience for lengthy content.

-

The backdrop remains fixed while the dialog content scrolls. Clicking outside the dialog box still closes it (unless using a static backdrop). Keyboard navigation and focus trapping work the same as standard dialogs.

-

Consider using .dialog-scrollable instead if you want the header and footer to remain visible while scrolling. The scrollable variant keeps navigation controls accessible at all times, which may be preferable for forms with submit buttons.

-

Both approaches leverage the native <dialog> element's top layer rendering. This ensures the dialog appears above all other content, including elements with high z-index values, fixed positioning, or transforms.

-

The choice between scrolling behaviors depends on your content and user experience goals. Document-like content often works well with overflow scrolling, while interactive content may benefit from fixed header and footer.

-

You've reached the bottom of the overflow dialog!

-
- -
-
`} /> - ## Swapping dialogs When a toggle trigger is inside an open dialog, clicking it will **swap** dialogs—opening the new one before closing the current. This ensures the backdrop stays visible throughout the transition with no flash. The swap behavior is automatic when a `data-bs-toggle="dialog"` trigger is inside an already-open dialog. diff --git a/site/src/content/docs/components/drawer.mdx b/site/src/content/docs/components/drawer.mdx index 901c662855..bc3e79a6af 100644 --- a/site/src/content/docs/components/drawer.mdx +++ b/site/src/content/docs/components/drawer.mdx @@ -11,11 +11,15 @@ js: required Drawer builds on the native `` element and our Dialog component to manage hidden sidebars via JavaScript. These sidebars, or drawers, can appear from the left, right, top, or bottom edge of the viewport. Buttons or anchors are used as triggers that are attached to specific elements you toggle, and `data` attributes are used to invoke our JavaScript. -- Drawer shares a common base with our [Dialog component]([[docsref:/components/dialog]]), leveraging native `` APIs for focus trapping, backdrop, and top-layer rendering. -- Drawer uses the `` element, which must be used instead of a `
`. -- When shown, drawer includes a native `::backdrop` that can be clicked to hide the drawer. -- Only one drawer can be shown at a time. -- Due to how CSS handles animations, you cannot use `margin` or `translate` on an `.drawer` element. Instead, use the class as an independent wrapping element. +- **Drawer shares the same base as [our Dialog component]([[docsref:/components/dialog]])**, using native `` APIs for focus trapping, backdrop, and top-layer rendering. +- **Use the `` element**, not a `
`. Due to how CSS handles animations, don't apply `margin` or `translate` directly to `.drawer` — wrap it if you need those. +- **Modal by default, or scroll-through** — the default opens the drawer modally with a backdrop; set `scroll: true` to render without a backdrop and without locking body scroll. +- **Built-in backdrop** via `::backdrop` (modal only) closes the drawer on click; set `backdrop: "static"` to lock clicks outside, or `backdrop: false` to hide it. +- **Escape key handling** closes the drawer by default; set `keyboard: false` to disable. +- **Swipe to dismiss** — drawer auto-wires a placement-aware swipe (swipe left to close `drawer-start` in LTR, swipe down to close `drawer-bottom`, etc.). +- **Responsive placement** — `.sm-drawer`, `.md-drawer`, and up stay drawer-like below their breakpoint and collapse inline above it, making drawers useful as responsive navbars. +- **Only one drawer can be shown at a time** (enforced by the data API). +- **Animated open and close** — drawer slides back out to its placement-specific off-screen position on close, with the `::backdrop` fading alongside it when opened modally. When authoring custom CSS, qualify your `[open]` rules with `:not(.hiding)` so the exit transition can fall through to the base state; add `.drawer-instant` to skip animations. diff --git a/site/src/content/docs/guides/migration.mdx b/site/src/content/docs/guides/migration.mdx index a621cf1a71..41b64da6e9 100644 --- a/site/src/content/docs/guides/migration.mdx +++ b/site/src/content/docs/guides/migration.mdx @@ -177,7 +177,7 @@ Bootstrap 6 is a major release with many breaking changes to modernize our codeb - CSS variables: `--modal-*` → `--dialog-*`. - Backdrop: The `.modal-backdrop` DOM element is gone — Dialog uses the native `::backdrop` pseudo-element with `backdrop-filter: blur()` support. - Body scroll prevention: `.modal-open` on `` → `.dialog-open` on the `` element. - - New variant classes: `.dialog-slide-up`, `.dialog-slide-down` (slide animations), `.dialog-instant` (no animation), `.dialog-static` (static backdrop bounce), `.dialog-nonmodal` (non-modal positioning), `.dialog-scrollable`, `.dialog-overflow`. + - New variant classes: `.dialog-slide-up`, `.dialog-slide-down` (slide animations), `.dialog-instant` (no animation), `.dialog-static` (static backdrop bounce), `.dialog-nonmodal` (non-modal positioning), `.dialog-scrollable`. - Non-modal support: Set `modal: false` or `data-bs-modal="false"` for non-modal dialogs. - Dialog swapping: Triggers inside an open dialog can open a new dialog and close the current one automatically. - See the [Dialog docs]([[docsref:/components/dialog]]) for full details. diff --git a/site/src/scss/_ads.scss b/site/src/scss/_ads.scss index ba858679ce..cfbfcb8c1e 100644 --- a/site/src/scss/_ads.scss +++ b/site/src/scss/_ads.scss @@ -13,10 +13,10 @@ border: 0 !important; @include border-radius(var(--bs-border-radius-lg) !important); } - #carbon-responsive a, - .carbon-description { - color: var(--bs-fg-1) !important; - } + // #carbon-responsive a, + // .carbon-description { + // color: var(--bs-fg-1) !important; + // } .carbon-img { position: relative; overflow: hidden; -- 2.47.3