| @use "sass:map"; |
| @use "config" as *; |
| @use "functions" as *; |
| @use "layout/breakpoints" as *; |
| @use "mixins/border-radius" as *; |
| @use "mixins/box-shadow" as *; |
| @use "mixins/dialog-shared" as *; |
| @use "mixins/transition" as *; |
| @use "mixins/tokens" as *; |
| |
| // Native <dialog> component |
| // Uses the browser's native dialog element with showModal()/show()/close() APIs |
| // Leverages native [open] attribute and ::backdrop pseudo-element |
| |
| // stylelint-disable custom-property-no-missing-var-function |
| $dialog-tokens: () !default; |
| |
| // scss-docs-start dialog-tokens |
| // stylelint-disable-next-line scss/dollar-variable-default |
| $dialog-tokens: defaults( |
| ( |
| --dialog-padding: 1rem, |
| --dialog-width: 500px, |
| --dialog-margin: 1.75rem, |
| --dialog-color: var(--fg-body), |
| --dialog-bg: var(--bg-body), |
| --dialog-border-color: var(--border-color-translucent), |
| --dialog-border-width: var(--border-width), |
| --dialog-border-radius: var(--radius-7), |
| --dialog-box-shadow: var(--box-shadow-xl), |
| --dialog-transition-property: "opacity, transform, visibility", |
| --dialog-transition-duration: .3s, |
| --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, |
| --dialog-header-border-color: var(--border-color-translucent), |
| --dialog-header-border-width: var(--border-width), |
| --dialog-footer-padding: 1rem, |
| --dialog-footer-border-color: var(--border-color-translucent), |
| --dialog-footer-border-width: var(--border-width), |
| --dialog-footer-gap: .5rem, |
| ), |
| $dialog-tokens |
| ); |
| // scss-docs-end dialog-tokens |
| // stylelint-enable custom-property-no-missing-var-function |
| |
| // scss-docs-start dialog-sizes |
| $dialog-sizes: () !default; |
| // stylelint-disable-next-line scss/dollar-variable-default |
| $dialog-sizes: defaults( |
| ( |
| sm: 280px, |
| lg: 800px, |
| xl: 1140px, |
| ), |
| $dialog-sizes |
| ); |
| // scss-docs-end dialog-sizes |
| |
| @layer components { |
| // Prevent page scroll when a dialog is open. Applied to the root element so |
| // `overflow: hidden` sits on the same element as `scrollbar-gutter: stable` |
| // (see _root.scss): the gutter stays reserved while the scrollbar is hidden, |
| // so the page doesn't shift when a dialog opens. |
| :root.dialog-open { |
| overflow: hidden; |
| } |
| |
| .dialog { |
| @include tokens($dialog-tokens); |
| |
| // Override UA display:none so visibility controls the hidden state, |
| // enabling reliable cross-browser exit animations after close(). |
| display: flex; |
| flex-direction: column; |
| width: var(--dialog-width); |
| max-width: calc(100% - var(--dialog-margin) * 2); |
| max-height: calc(100% - var(--dialog-margin) * 2); |
| padding: 0; |
| margin: auto; |
| overflow: visible; |
| color: var(--dialog-color); |
| visibility: hidden; |
| 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)); |
| |
| // Animated variant (default) — transitions, opacity fade, slide transforms. |
| // Adding .dialog-instant skips all animations (instant show/hide). |
| &:not(.dialog-instant) { |
| // Exit state: faded out |
| opacity: 0; |
| |
| // One transition covers both directions. `visibility` animates with the |
| // rest because it interpolates as a step that stays `visible` while |
| // either end is visible: it flips visible at once on entry, and only |
| // after the animation on exit. Keep it in --dialog-transition-property |
| // when you override the list, or the exit becomes invisible. |
| @include transition-props( |
| var(--dialog-transition-property), |
| var(--dialog-transition-duration), |
| var(--dialog-transition-timing) |
| ); |
| |
| // 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 <dialog> element). |
| &.dialog-slide-down { |
| transform: translateY(-3rem); |
| } |
| |
| // 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); |
| } |
| |
| // Open state: visible and faded in. |
| // 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; |
| transform: none; |
| } |
| |
| // 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); |
| } |
| |
| // Native backdrop styling with transitions |
| &::backdrop { |
| background-color: var(--dialog-backdrop-bg); |
| 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 |
| &.dialog-instant { |
| &::backdrop { |
| background-color: var(--dialog-backdrop-bg); |
| backdrop-filter: blur(var(--dialog-backdrop-blur)); |
| } |
| } |
| |
| // 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; |
| transform: none; |
| } |
| |
| // Non-modal dialog positioning |
| // `show()` doesn't use the top layer, so we need explicit positioning and |
| // z-index. Center with inset + `margin: auto` (already on `.dialog`) and |
| // `height: fit-content` so the open-state `transform: none` doesn't un-center it. |
| &.dialog-nonmodal { |
| position: fixed; |
| inset: 0; |
| z-index: var(--z-dialog); |
| height: fit-content; |
| } |
| |
| // Scrollable dialog body (header/footer stay fixed) |
| &.dialog-scrollable[open] { |
| max-height: calc(100% - var(--dialog-margin) * 2); |
| |
| .dialog-body { |
| overflow-y: auto; |
| } |
| } |
| } |
| |
| // 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 { |
| .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 |
| @each $size, $value in $dialog-sizes { |
| .dialog-#{$size} { --dialog-width: #{$value}; } |
| } |
| |
| // Fullscreen dialog |
| .dialog-fullscreen { |
| --dialog-width: 100vw; |
| --dialog-margin: 0; |
| --dialog-border-radius: 0; |
| --dialog-border-width: 0; |
| |
| width: 100%; |
| max-width: none; |
| height: 100%; |
| max-height: none; |
| } |
| |
| // Responsive fullscreen dialogs |
| @each $breakpoint in map.keys($breakpoints) { |
| $prefix: breakpoint-prefix($breakpoint, $breakpoints); |
| |
| @if $prefix != "" { |
| @include media-breakpoint-down($breakpoint) { |
| .#{css-escape-ident($breakpoint)}-down\:dialog-fullscreen { |
| --dialog-width: 100vw; |
| --dialog-margin: 0; |
| --dialog-border-radius: 0; |
| |
| width: 100%; |
| max-width: none; |
| height: 100%; |
| max-height: none; |
| } |
| } |
| } |
| } |
| |
| // Dialog header |
| .dialog-header { |
| @include dialog-header(var(--dialog-header-padding)); |
| border-block-end: var(--dialog-header-border-width) solid var(--dialog-header-border-color); |
| |
| .btn-close { |
| margin-inline-start: auto; |
| } |
| } |
| |
| // Dialog title |
| .dialog-title { |
| @include dialog-title(); |
| font-size: var(--font-size-md); |
| } |
| |
| // Dialog body |
| .dialog-body { |
| position: relative; |
| @include dialog-body(var(--dialog-padding)); |
| } |
| |
| // Dialog footer |
| .dialog-footer { |
| @include dialog-footer(var(--dialog-footer-padding), var(--dialog-footer-gap), var(--dialog-footer-border-width), var(--dialog-footer-border-color)); |
| } |
| } |