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>