1/** 2 * Custom Web Component: <carousel-element> â A horizontal scrolling carousel container with navigation controls. 3 * 4 * Description: 5 * This component provides a horizontally scrollable container that displays slotted child elements 6 * (typically card components) in a row. It includes: 7 * - Previous/Next navigation buttons that auto-disable at scroll boundaries. 8 * - Slotted heading and description for a customizable header area. 9 * - Responsive behavior: hides nav buttons on mobile (<600px) and enables touch scroll with snap. 10 * - ResizeObserver-based state updates for dynamic content. 11 * 12 * Shadow DOM Structure (default): 13 * <div class="carousel-container"> 14 * <div class="carousel-header"> 15 * <div class="header-content"> 16 * <slot name="heading"></slot> 17 * <slot name="description"></slot> 18 * </div> 19 * <div class="nav-buttons"> 20 * <button class="nav-button prev">...</button> 21 * <button class="nav-button next">...</button> 22 * </div> 23 * </div> 24 * <div class="scroll-container"> 25 * <slot></slot> <!-- Default slot for card children --> 26 * </div> 27 * </div> 28 * 29 * Shadow DOM Structure (side-arrows attribute present): 30 * <div class="carousel-container"> 31 * <div class="carousel-header"> 32 * <div class="header-content"> 33 * <slot name="heading"></slot> 34 * <slot name="description"></slot> 35 * </div> 36 * </div> 37 * <div class="carousel-body"> <!-- position:relative anchor for the buttons --> 38 * <button class="nav-button prev">...</button> <!-- absolutely positioned, vertically centered, left edge --> 39 * <div class="scroll-container"> 40 * <slot></slot> 41 * </div> 42 * <button class="nav-button next">...</button> <!-- absolutely positioned, vertically centered, right edge --> 43 * </div> 44 * </div> 45 * 46 * Attributes: 47 * - gap (string): Currently observed but not yet applied via CSS variable. Reserved for future gap customization. 48 * - side-arrows (boolean): Opt-in layout variant -- nav buttons flank the scroll area itself (one pinned 49 * left, one pinned right, vertically centered) instead of sitting in the header row next to the 50 * heading/description. Default (attribute absent) is the original header placement, so every existing 51 * carousel on the site keeps its current look; only pages that explicitly want the side layout opt in. 52 * Either layout auto-disables the prev/next button (existing .disabled toggling in _updateState()) when 53 * there's nothing further to scroll in that direction -- prev disabled at the start, next at the end. 54 * 55 * Slots: 56 * - "heading": Heading element (e.g., <a> or <span>) displayed above the carousel. 57 * - "description": Description text displayed below the heading. 58 * - default (unnamed): Card elements to scroll through (e.g., <carousel-image-card>, <carousel-text-list-card>). 59 * 60 * Responsive Breakpoints padding on left most and right most: 61 * - Expanded (840px+): 48px horizontal padding. 62 * - Medium (600-839px): 24px horizontal padding. 63 * - Compact (<600px): 16px horizontal padding, nav buttons hidden, scroll-snap enabled. 64 * 65 * Usage: 66 * <carousel-element> 67 * <a slot="heading" href="#">Section Title</a> 68 * <span slot="description">Description text</span> 69 * <carousel-image-card ...></carousel-image-card> 70 * <carousel-image-card ...></carousel-image-card> 71 * </carousel-element> 72 * 73 * <carousel-element side-arrows> 74 * <carousel-image-card ...></carousel-image-card> 75 * <carousel-image-card ...></carousel-image-card> 76 * </carousel-element> 77 */ 78import { WebComponent } from './Base/WebComponent.js'; 79 80class Carousel extends WebComponent { 81 constructor() { 82 super(); 83 this._isMobile = false; 84 85 // Placeholder for styling 86 const style = document.createElement('style'); 87 style.textContent = ``; 88 } 89 90 static get observedAttributes() { 91 return ['gap', 'side-arrows']; 92 } 93 94 render() { 95 const sideArrows = this.hasAttribute('side-arrows'); 96 97 const prevButton = ` 98 <button class="nav-button prev disabled" aria-label="Previous"> 99 <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> 100 <path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/> 101 </svg> 102 </button> 103 `; 104 const nextButton = ` 105 <button class="nav-button next" aria-label="Next"> 106 <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> 107 <path d="M8.59 16.59L10 18l6-6-6-6-1.41 1.41L13.17 12z"/> 108 </svg> 109 </button> 110 `; 111 112 this.loadTemplate(` 113 <style> 114 :host { 115 display: block; 116 } 117 .carousel-header { 118 display: flex; 119 flex-direction: row;
120 align-items: flex-end; 121 gap: 24px; 122 margin-bottom: 24px; 123 } 124 .header-content { 125 display: flex; 126 flex-direction: column; 127 flex: 1; 128 } 129 .nav-buttons { 130 display: flex; 131 align-items: center; 132 gap: var(--spacing-space-8, 8px); 133 flex-shrink: 0; 134 } 135 .nav-button { 136 border-radius: 50%; 137 background: var(--color-bg-on-brand-primary, #038580); 138 display: flex; 139 padding: var(--spacing-space-6, 6px); 140 justify-content: center; 141 align-items: center; 142 gap: 10px; 143 border: none; 144 cursor: pointer; 145 transition: opacity 0.2s; 146 } 147 .nav-button:hover { 148 opacity: 0.85; 149 } 150 .nav-button svg { 151 width: 20px; 152 height: 20px; 153 fill: #fff; 154 } 155 156 .nav-button.disabled svg { 157 width: 20px; 158 height: 20px; 159 fill: #9DA6A5; 160 } 161 .nav-button.disabled { 162 cursor: default; 163 pointer-events: none; 164 border-radius: 50%; 165 background: #D8DBDB; 166 } 167 .scroll-container { 168 display: flex; 169 gap: 24px; 170 overflow-x: auto; 171 scrollbar-width: none; 172 scroll-behavior: smooth; 173 } 174 .scroll-container::-webkit-scrollbar {
175 display: none; 176 } 177 .carousel-container { 178 padding: 0 48px; 179 } 180 ::slotted(:not([slot])) { 181 flex-shrink: 0; 182 align-self: stretch; 183 } 184 .dot { 185 width: 8px; 186 height: 8px; 187 border-radius: 50%; 188 background: #D1D5DB; 189 border: none; 190 padding: 0; 191 cursor: pointer; 192 transition: background 0.2s; 193 } 194 .dot.active { 195 background: #1a1a1a; 196 } 197 198 ::slotted([slot="heading"]) { 199 font-size: 24px; 200 font-weight: 600; 201 line-height: 32px; 202 color: #026E80; 203 cursor: pointer; 204 text-decoration: none; 205 } 206 ::slotted([slot="description"]) { 207 font-size: 16px; 208 font-weight: 400; 209 line-height: 24px; 210 color: var(--color-text-secondary, #5E6463); 211 display: block; 212 margin-top: 10px !important; 213 } 214 215 /* side-arrows variant only: buttons flank the scroll area itself (one left, one 216 right, vertically centered) instead of sitting in the header row. Scoped so 217 the default carousel used elsewhere on the site is completely unaffected. */ 218 ${sideArrows ? ` 219 .carousel-body { 220 position: relative; 221 } 222 .nav-button { 223 position: absolute; 224 top: 50%; 225 transform: translateY(-50%); 226 z-index: 2; 227 } 228 .nav-button.prev { 229 left: 8px; 230 } 231 .nav-button.next { 232 right: 8px; 233 } 234 ` : ''} 235 236 /* Expanded: 840px+ */ 237 @media (min-width: 840px) { 238 .carousel-container { 239 padding: 0 48px; 240 } 241 } 242 /* Medium: 600-839px */ 243 @media (min-width: 600px) and (max-width: 839px) { 244 .carousel-container { 245 padding: 0 24px; 246 } 247 } 248 /* Compact: under 600px â hide buttons, touch scroll */ 249 @media (max-width: 599px) { 250 .carousel-container { 251 padding: 0 16px; 252 } 253 ::slotted(:not([slot])) { 254 scroll-snap-align: start; 255 } 256 .nav-buttons { 257 display: none; 258 } 259 ${sideArrows ? '.nav-button { display: none; }' : ''} 260 } 261 262 </style> 263 <div class="carousel-container"> 264 <div class="carousel-header"> 265 <div class="header-content"> 266 <slot name="heading"></slot> 267 <slot name="description"></slot> 268 </div> 269 ${sideArrows ? '' : `<div class="nav-buttons">${prevButton}${nextButton}</div>`} 270 </div> 271 ${sideArrows ? ` 272 <div class="carousel-body"> 273 ${prevButton} 274 <div class="scroll-container"> 275 <slot></slot> 276 </div> 277 ${nextButton} 278 </div> 279 ` : ` 280 <div class="scroll-container"> 281 <slot></slot> 282 </div> 283 `} 284 </div> 285 `); 286 } 287 288 setEventListeners() { 289 this._scrollContainer = this.shadowRoot.querySelector('.scroll-container'); 290 this._prevBtn = this.shadowRoot.querySelector('.nav-button.prev'); 291 this._nextBtn = this.shadowRoot.querySelector('.nav-button.next'); 292 this._slot = this.shadowRoot.querySelector('slot:not([name])'); 293 294 this._prevBtn.addEventListener('click', () => this._scroll(-1)); 295 this._nextBtn.addEventListener('click', () => this._scroll(1)); 296 this._scrollContainer.addEventListener('scroll', () => this._updateState()); 297 298 this._slot.addEventListener('slotchange', () => { 299 this._updateState(); 300 }); 301 302 this._setupResizeObserver(); 303 this._setupMediaQuery(); 304 305 requestAnimationFrame(() => { 306 this._updateState(); 307 }); 308 } 309 310 disconnectedCallback() { 311 if (this._resizeObserver) { 312 this._resizeObserver.disconnect(); 313 } 314 if (this._mqMobile && this._mqMobileHandler) { 315 this._mqMobile.removeEventListener('change', this._mqMobileHandler); 316 } 317 } 318 319 attributeChangedCallback(name, oldVal, newVal) { 320 if (name === 'gap') { 321 this._updateState(); 322 } else if (name === 'side-arrows' && this.shadowRoot.childNodes.length) { 323 this.render(); 324 } 325 } 326 327 _scroll(direction) { 328 const items = this._slot.assignedElements(); 329 if (items.length === 0) return; 330 331 // Scroll by one "page" worth of items 332 const scrollAmount = this._scrollContainer.clientWidth * direction; 333 this._scrollContainer.scrollBy({ left: scrollAmount, behavior: 'smooth' }); 334 } 335 336 _scrollToPage(pageIndex) { 337 const pageWidth = this._scrollContainer.clientWidth; 338 this._scrollContainer.scrollTo({ left: pageWidth * pageIndex, behavior: 'smooth' }); 339 } 340 341 _getPageCount() { 342 const { scrollWidth, clientWidth } = this._scrollContainer; 343 if (clientWidth === 0) return 1; 344 return Math.max(1, Math.ceil(scrollWidth / clientWidth)); 345 } 346 347 _getCurrentPage() { 348 const { scrollLeft, clientWidth } = this._scrollContainer; 349 if (clientWidth === 0) return 0; 350 return Math.round(scrollLeft / clientWidth); 351 } 352 353 _updateState() { 354 const { scrollLeft, scrollWidth, clientWidth } = this._scrollContainer; 355 const allFit = scrollWidth <= clientWidth; 356 357 // Update button states 358 const atStart = scrollLeft <= 0; 359 const atEnd = Math.ceil(scrollLeft) >= scrollWidth - clientWidth; 360 361 this._prevBtn.classList.toggle('disabled', atStart || allFit); 362 this._nextBtn.classList.toggle('disabled', atEnd || allFit); 363 } 364 365 _setupResizeObserver() { 366 this._resizeObserver = new ResizeObserver(() => { 367 this._updateState(); 368 }); 369 this._resizeObserver.observe(this._scrollContainer); 370 } 371 372 _setupMediaQuery() { 373 this._mqMobile = window.matchMedia('(max-width: 599px)'); 374 this._mqMobileHandler = (e) => { 375 this._isMobile = e.matches; 376 this._updateState(); 377 }; 378 this._mqMobile.addEventListener('change', this._mqMobileHandler); 379 this._isMobile = this._mqMobile.matches; 380 } 381} 382 383if (!customElements.get('carousel-element')) { 384 customElements.define('carousel-element', Carousel); 385}
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.