1/** 2 * _ _ ___ __ __ 3 * | | | |( _ )\ \ / / 4 * | |_| |/ _ \/\ \ /\ / / 5 * | _ | (_> <\ V V / 6 * |_| |_|\___/\/ \_/\_/ 7 * 8 * @file Dialog.js 9 * @version 2.0 10 * @description Custom element for animated <dialog> management. 11 * 12 * Registers a custom element (hw13-dialog or hw13-dialog-{name}) that: 13 * - Opens/closes via data-open attribute (observed, triggers animations) 14 * - Manages aria-expanded on associated buttons (inside = close, outside = open) 15 * - Reads animation parameters from CSS custom properties for full customization 16 * - Handles Escape key, backdrop click, and prefers-reduced-motion 17 * 18 * CSS custom properties read from the <dialog> element: 19 * - --hw13-dialog-animation-duration (default: 400ms) 20 * - --hw13-dialog-animation-open-timing (default: cubic-bezier(0, 0.5, 0.5, 1)) 21 * - --hw13-dialog-animation-close-timing (default: cubic-bezier(0.5, 0, 1, 0.5)) 22 * - --hw13-dialog-animation-closed-transform (default: scale(0.95)) 23 * - --hw13-dialog-animation-open-transform (default: scale(1)) 24 * - --hw13-dialog-animation-closed-opacity (default: 0) 25 * - --hw13-dialog-animation-open-opacity (default: 1) 26 * 27 * Loading: 28 * - Without name param: registers <hw13-dialog> 29 * - With ?name=xyz: registers <hw13-dialog-xyz> 30 */ 31 32const OPEN_ATTR = 'data-open'; 33 34class Hw13Dialog extends HTMLElement { 35 static observedAttributes = [OPEN_ATTR]; 36 37 constructor() { 38 super(); 39 this._dialogElement = null; 40 this._buttonsInside = null; 41 this._buttonsOutside = null; 42 this._currentAnimation = null; 43 } 44 45 // --- Element Getters (lazy, cached) --- 46 47 get dialogElement() { 48 if (!this._dialogElement) { 49 this._dialogElement = this.querySelector(':scope > dialog'); 50 } 51 return this._dialogElement; 52 } 53 54 get buttonsInside() { 55 if (!this._buttonsInside && this.dialogElement) { 56 this._buttonsInside = Array.from( 57 this.querySelectorAll( 58 `:scope > dialog button[aria-controls="${this.dialogElement.id}"]`, 59 ), 60 ); 61 } 62 return this._buttonsInside || []; 63 } 64 65 get buttonsOutside() { 66 if (!this._buttonsOutside && this.dialogElement) { 67 this._buttonsOutside = Array.from( 68 document.querySelectorAll(`button[aria-controls="${this.dialogElement.id}"]`), 69 ).filter((btn) => !this.buttonsInside.includes(btn)); 70 } 71 return this._buttonsOutside || []; 72 } 73 74 get allButtons() { 75 return [...this.buttonsInside, ...this.buttonsOutside]; 76 } 77 78 // --- Lifecycle --- 79 80 connectedCallback() { 81 if (!this.dialogElement) return; 82 83 this.buttonsInside.forEach((btn) => { 84 btn.addEventListener('click', () => { 85 this.setAttribute(OPEN_ATTR, 'false'); 86 }); 87 }); 88 89 this.buttonsOutside.forEach((btn) => { 90 btn.addEventListener('click', () => { 91 this.setAttribute(OPEN_ATTR, 'true'); 92 }); 93 }); 94 95 // Prevent native Escape from closing without animation 96 this.dialogElement.addEventListener('cancel', (e) => { 97 e.preventDefault(); 98 this.setAttribute(OPEN_ATTR, 'false'); 99 }); 100 101 // Sync data-open when the dialog is closed by any means 102 // (native close(), form method="dialog", unhandled cancel, etc.) 103 this.dialogElement.addEventListener('close', () => { 104 if (this.getAttribute(OPEN_ATTR) === 'true') { 105 this.setAttribute(OPEN_ATTR, 'false'); 106 } 107 }); 108 109 // Close on backdrop click 110 this.dialogElement.addEventListener('click', (e) => { 111 if (e.target === this.dialogElement) { 112 this.setAttribute(OPEN_ATTR, 'false'); 113 } 114 }); 115 116 // Handle initial open state 117 if (this.getAttribute(OPEN_ATTR) === 'true' && !this.dialogElement.open) { 118 this.open(); 119 } 120 } 121 122 attributeChangedCallback(name, oldValue, newValue) { 123 if (oldValue === null || oldValue === newValue) return; 124 125 if (newValue === 'true') { 126 this.open(); 127 } else { 128 this.close(); 129 } 130 131 this.allButtons.forEach((btn) => { 132 btn.setAttribute('aria-expanded', newValue); 133 }); 134 } 135 136 // --- Animation Config (read from CSS custom properties) --- 137 138 getAnimationConfig() { 139 const dialog = this.dialogElement; 140 if (!dialog) return {}; 141 142 const styles = getComputedStyle(dialog); 143 const prop = (name, fallback) => styles.getPropertyValue(name).trim() || fallback; 144 145 return { 146 duration: parseInt(prop('--hw13-dialog-animation-duration', '400'), 10), 147 openTiming: prop('--hw13-dialog-animation-open-timing', 'cubic-bezier(0, 0.5, 0.5, 1)'), 148 closeTiming: prop( 149 '--hw13-dialog-animation-close-timing', 150 'cubic-bezier(0.5, 0, 1, 0.5)', 151 ), 152 closedTransform: prop('--hw13-dialog-animation-closed-transform', 'scale(0.95)'), 153 openTransform: prop('--hw13-dialog-animation-open-transform', 'scale(1)'), 154 closedOpacity: prop('--hw13-dialog-animation-closed-opacity', '0'), 155 openOpacity: prop('--hw13-dialog-animation-open-opacity', '1'), 156 }; 157 } 158 159 prefersReducedMotion() { 160 return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; 161 } 162 163 cancelCurrentAnimation() { 164 this._currentAnimation?.cancel(); 165 this._currentAnimation = null; 166 } 167 168 // --- Open / Close --- 169 170 open() { 171 const element = this.dialogElement; 172 if (!element) return; 173 174 const config = this.getAnimationConfig(); 175 this.cancelCurrentAnimation(); 176 element.showModal(); 177 178 if (this.prefersReducedMotion()) return; 179 180 this._currentAnimation = element.animate( 181 [ 182 { 183 transform: config.closedTransform, 184 opacity: config.closedOpacity, 185 }, 186 { 187 transform: config.openTransform, 188 opacity: config.openOpacity, 189 }, 190 ], 191 { 192 duration: config.duration, 193 easing: config.openTiming, 194 fill: 'forwards', 195 }, 196 ); 197 198 this._currentAnimation.finished 199 .then(() => { 200 this._currentAnimation = null; 201 element.style.transform = ''; 202 element.style.opacity = ''; 203 }) 204 .catch(() => { 205 /* cancelled */ 206 }); 207 } 208 209 close() { 210 const element = this.dialogElement; 211 if (!element) return; 212 213 const config = this.getAnimationConfig(); 214 this.cancelCurrentAnimation(); 215 216 if (this.prefersReducedMotion()) { 217 element.close(); 218 return; 219 } 220 221 this._currentAnimation = element.animate( 222 [ 223 { 224 transform: config.openTransform, 225 opacity: config.openOpacity, 226 }, 227 { 228 transform: config.closedTransform, 229 opacity: config.closedOpacity, 230 }, 231 ], 232 { 233 duration: config.duration, 234 easing: config.closeTiming, 235 fill: 'forwards', 236 }, 237 ); 238 239 this._currentAnimation.finished 240 .then(() => { 241 this._currentAnimation = null; 242 element.style.transform = ''; 243 element.style.opacity = ''; 244 element.close(); 245 }) 246 .catch(() => { 247 /* cancelled */ 248 }); 249 } 250} 251 252// --- Registration --- 253// Without ?name param: registers hw13-dialog (standard) 254// With ?name=xyz: registers hw13-dialog-xyz (custom variant) 255 256function register() { 257 const url = new URL(import.meta.url); 258 const name = url.searchParams.get('name'); 259 const tagName = name ? `hw13-dialog-${name}` : 'hw13-dialog'; 260 261 if (!customElements.get(tagName)) { 262 customElements.define(tagName, class extends Hw13Dialog {}); 263 } 264} 265 266if (document.readyState === 'complete') { 267 register(); 268} else { 269 document.addEventListener('readystatechange', () => { 270 if (document.readyState === 'complete') { 271 register(); 272 } 273 }); 274}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.