/ shared / components / src / components / Modal / Modal.svelte
Modal.svelte
  1  <script lang="ts">
  2      import { onMount, createEventDispatcher } from 'svelte';
  3  
  4      const dispatch = createEventDispatcher();
  5  
  6      export let modalTriggerElement: HTMLElement | null;
  7      export let error: boolean = false;
  8      export let dialogId: string = '';
  9      export let dialogClassNames: string = '';
 10  
 11      /**
 12       * Disable the background scrim for this modal. Used with fullscreen modal
 13       * variants that don't apply a scrim while transitioning in or out of view.
 14       */
 15      export let disableScrim: boolean = false;
 16  
 17      /**
 18       * Whether to immediately display the modal when the component is mounted.
 19       */
 20      export let showOnMount: boolean = false;
 21  
 22      /**
 23       * If true, suppress the default `close` event fired by the native <dialog> element.
 24       * Instead, a `close` event is dispatched to be handled by the consuming component.
 25       * This is useful for modals that implement custom transitions and need to wait for
 26       * transitions to end on child elements before <dialog> removes them from the DOM.
 27       *
 28       * Note that if this option is used, the consuming component *must* call `close()`
 29       * on this component to properly close the modal!
 30       */
 31      export let preventDefaultClose: boolean = false;
 32  
 33      /**
 34       * ID for element that contains accessible modal title.
 35       */
 36      export let ariaLabelledBy: string | null = null;
 37  
 38      /**
 39       * Accessible modal title. Note that this should only be used when there is no element
 40       * containing the modal title that can be associated using `ariaLabelledBy`.
 41       */
 42      export let ariaLabel: string | null = null;
 43  
 44      let ariaHidden: boolean = true;
 45  
 46      let dialogElement: HTMLDialogElement;
 47      let needsPolyfill: boolean = false;
 48      let isDialogInShadow: boolean;
 49  
 50      export function showModal() {
 51          // noscroll class ensures that when this component is in a shadow DOM context,
 52          // the parent app can control the background scroll behavior
 53          document.body.classList.add('noscroll');
 54  
 55          /*
 56              in non-shadow DOM contexts, add the dialog directly to the body to
 57              avoid stacking context issues where the the dialog hides behind side nav on Music
 58              see: https://github.com/GoogleChrome/dialog-polyfill#stacking-context
 59              if the dialog is within the shadow DOM (being used as a web component)
 60              do not append to the body and use showModal method to keep dialog within the shadow DOM
 61          */
 62          if (needsPolyfill) {
 63              isDialogInShadow = isInShadow(dialogElement);
 64              if (!isDialogInShadow) {
 65                  document.body.appendChild(dialogElement);
 66              }
 67          }
 68          ariaHidden = false;
 69          dialogElement.showModal();
 70      }
 71  
 72      export function close() {
 73          document.body.classList.remove('noscroll');
 74  
 75          // in non-shadow DOM + polyfill instances we added the dialog
 76          // directly to the body, this removes it
 77          if (needsPolyfill && !isDialogInShadow) {
 78              document.body.removeChild(dialogElement);
 79          }
 80  
 81          ariaHidden = true;
 82          dialogElement.close();
 83          modalTriggerElement?.focus();
 84      }
 85  
 86      function handleClose(e: Event) {
 87          if (preventDefaultClose) {
 88              e.preventDefault();
 89          } else {
 90              close();
 91          }
 92          dispatch('close');
 93      }
 94  
 95      function isInShadow(node: HTMLElement | ParentNode) {
 96          for (; node; node = node.parentNode) {
 97              if (node.toString() === '[object ShadowRoot]') {
 98                  return true;
 99              }
100          }
101          return false;
102      }
103  
104      onMount(async () => {
105          // register polyfill for native <dialog> element if needed
106          needsPolyfill = !('showModal' in dialogElement);
107          if (needsPolyfill) {
108              const { default: dialogPolyfill } = await import('dialog-polyfill');
109              dialogPolyfill.registerDialog(dialogElement);
110              dialogElement.classList.add('dialog-polyfill');
111          }
112  
113          if (showOnMount) {
114              showModal();
115          }
116      });
117  </script>
118  
119  <!--
120    @component
121    Dialog element wrapping a slot.
122    This component is multipurpose and should be used
123    anywhere a centered modal with a backdrop is needed
124   -->
125  <!-- svelte-ignore a11y-click-events-have-key-events -->
126  <!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
127  <dialog
128      data-testid="dialog"
129      class:error
130      class:no-scrim={disableScrim}
131      class={dialogClassNames}
132      class:needs-polyfill={needsPolyfill}
133      id={dialogId}
134      bind:this={dialogElement}
135      on:click|self={handleClose}
136      on:close={handleClose}
137      on:cancel={handleClose}
138      aria-labelledby={ariaLabelledBy}
139      aria-label={ariaLabel}
140      aria-hidden={ariaHidden}
141  >
142      <slot {handleClose} />
143  </dialog>
144  
145  <style lang="scss">
146      @use '@amp/web-shared-styles/app/core/globalvars' as *;
147  
148      /* dialog polyfill styles need to be available
149       globally to avoid being stripped out */
150      :global(.needs-polyfill) {
151          position: absolute;
152          left: 0;
153          right: 0;
154          width: fit-content;
155          height: fit-content;
156          margin: auto;
157          border: solid;
158          padding: 1em;
159          background: white;
160          color: black;
161          display: block;
162  
163          &:not([open]) {
164              display: none;
165          }
166  
167          & + .backdrop {
168              position: fixed;
169              top: 0;
170              right: 0;
171              bottom: 0;
172              left: 0;
173              background: rgba(0, 0, 0, 0.1);
174          }
175  
176          &._dialog_overlay {
177              position: fixed;
178              top: 0;
179              right: 0;
180              bottom: 0;
181              left: 0;
182          }
183  
184          &.fixed {
185              position: fixed;
186              top: 50%;
187              transform: translate(0, -50%);
188          }
189      }
190  
191      /* dialog polyfill sets position: absolute - this
192       needs to be reset to ensure the dialog does not
193       scroll to top on open */
194      dialog:modal {
195          position: fixed;
196      }
197  
198      dialog {
199          width: var(--modalWidth, fit-content);
200          height: var(--modalHeight, fit-content);
201          max-width: var(--modalMaxWidth, initial);
202          max-height: var(--modalMaxHeight, initial);
203          border-radius: var(--modalBorderRadius, $modal-border-radius);
204          border: 0;
205          padding: 0;
206          color: var(--systemPrimary);
207          background: transparent;
208  
209          // Hide scrollbar while opening sliding modal
210          overflow: var(--modalOverflow, auto);
211          top: var(--modalTop, 0);
212          font: var(--body);
213  
214          &:focus {
215              outline: none;
216          }
217  
218          &::backdrop,
219          & + :global(.backdrop) /* for polyfill */ {
220              background-color: var(--modalScrimColor, rgba(0, 0, 0, 0.45));
221          }
222  
223          // ::backdrop does not inherit from anything, so CSS properties must be set on
224          // it directly in order to have any effect.
225          &.no-scrim::backdrop,
226          &.no-scrim + :global(.backdrop) {
227              --modalScrimColor: transparent;
228          }
229      }
230  
231      // disable error modal animation until svelte animations are implemented
232      // rdar://92356192 (JMOTW: Error Modal: Use Svelte animations)
233      // $error-modal-duration: 0.275s;
234      // dialog.error {
235      //     box-shadow: $dialog-inset-shadow, $dialog-shadow;
236      //     animation-name: modalZoomIn;
237      //     animation-duration: $error-modal-duration;
238      //     animation-timing-function: cubic-bezier(0.27, 1.01, 0.43, 1.19);
239      // }
240      // @keyframes modalZoomIn {
241      //     from {
242      //         opacity: 0;
243      //         transform: scale3d(0, 0, 0);
244      //     }
245      // }
246  </style>