Copybara bot | be50d49 | 2023-11-30 00:16:42 +0100 | [diff] [blame] | 1 | /** |
| 2 | * @license |
| 3 | * Copyright 2016 Leif Olsen. All Rights Reserved. |
| 4 | * |
| 5 | * Licensed under the Apache License, Version 2.0 (the "License"); |
| 6 | * you may not use this file except in compliance with the License. |
| 7 | * You may obtain a copy of the License at |
| 8 | * |
| 9 | * http://www.apache.org/licenses/LICENSE-2.0 |
| 10 | * |
| 11 | * Unless required by applicable law or agreed to in writing, software |
| 12 | * distributed under the License is distributed on an "AS IS" BASIS, |
| 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 14 | * See the License for the specific language governing permissions and |
| 15 | * limitations under the License. |
| 16 | * |
| 17 | * This code is built with Google Material Design Lite, |
| 18 | * which is Licensed under the Apache License, Version 2.0 |
| 19 | */ |
| 20 | |
| 21 | /** |
| 22 | * A lightboard is a translucent surface illuminated from behind, used for situations |
| 23 | * where a shape laid upon the surface needs to be seen with high contrast. In the "old days" of photography |
| 24 | * photograpers used a lightboard to get a quick view of their slides. The goal is to create a responsive lightbox |
| 25 | * design, based on flex layout, similar to what is used in Adobe LightRoom to browse images. |
| 26 | */ |
| 27 | |
| 28 | import { |
| 29 | VK_ENTER, |
| 30 | VK_SPACE, |
| 31 | VK_END, |
| 32 | VK_HOME, |
| 33 | VK_ARROW_LEFT, |
| 34 | VK_ARROW_UP, |
| 35 | VK_ARROW_RIGHT, |
| 36 | VK_ARROW_DOWN, |
| 37 | IS_UPGRADED, |
| 38 | MDL_RIPPLE, |
| 39 | MDL_RIPPLE_COMPONENT, |
| 40 | MDL_RIPPLE_EFFECT, |
| 41 | MDL_RIPPLE_EFFECT_IGNORE_EVENTS |
| 42 | } from '../utils/constants'; |
| 43 | |
| 44 | const MDL_RIPPLE_CONTAINER = 'mdlext-lightboard__slide__ripple-container'; |
| 45 | |
| 46 | (function() { |
| 47 | 'use strict'; |
| 48 | |
| 49 | //const LIGHTBOARD = 'mdlext-lightboard'; |
| 50 | const LIGHTBOARD_ROLE = 'grid'; |
| 51 | const SLIDE = 'mdlext-lightboard__slide'; |
| 52 | const SLIDE_ROLE = 'gridcell'; |
| 53 | const SLIDE_TABSTOP = 'mdlext-lightboard__slide__frame'; |
| 54 | /** |
| 55 | * @constructor |
| 56 | * @param {Element} element The element that will be upgraded. |
| 57 | */ |
| 58 | const MaterialExtLightboard = function MaterialExtLightboard(element) { |
| 59 | // Stores the element. |
| 60 | this.element_ = element; |
| 61 | |
| 62 | // Initialize instance. |
| 63 | this.init(); |
| 64 | }; |
| 65 | window['MaterialExtLightboard'] = MaterialExtLightboard; |
| 66 | |
| 67 | |
| 68 | // Helpers |
| 69 | const getSlide = element => { |
| 70 | return element ? element.closest(`.${SLIDE}`) : null; |
| 71 | }; |
| 72 | |
| 73 | |
| 74 | |
| 75 | // Private methods. |
| 76 | |
| 77 | /** |
| 78 | * Select a slide, i.e. set aria-selected="true" |
| 79 | * @param element |
| 80 | * @private |
| 81 | */ |
| 82 | MaterialExtLightboard.prototype.selectSlide_ = function(element) { |
| 83 | const slide = getSlide(element); |
| 84 | if( slide && !slide.hasAttribute('aria-selected') ) { |
| 85 | [...this.element_.querySelectorAll(`.${SLIDE}[aria-selected="true"]`)] |
| 86 | .forEach(selectedSlide => selectedSlide.removeAttribute('aria-selected')); |
| 87 | |
| 88 | slide.setAttribute('aria-selected', 'true'); |
| 89 | } |
| 90 | }; |
| 91 | |
| 92 | |
| 93 | /** |
| 94 | * Dispatch select event |
| 95 | * @param {Element} slide The slide that caused the event |
| 96 | * @private |
| 97 | */ |
| 98 | MaterialExtLightboard.prototype.dispatchSelectEvent_ = function ( slide ) { |
| 99 | this.element_.dispatchEvent( |
| 100 | new CustomEvent('select', { |
| 101 | bubbles: true, |
| 102 | cancelable: true, |
| 103 | detail: { source: slide } |
| 104 | }) |
| 105 | ); |
| 106 | }; |
| 107 | |
| 108 | /** |
| 109 | * Handles custom command event, 'first', 'next', 'prev', 'last', 'select' or upgrade |
| 110 | * @param event. A custom event |
| 111 | * @private |
| 112 | */ |
| 113 | MaterialExtLightboard.prototype.commandHandler_ = function( event ) { |
| 114 | event.preventDefault(); |
| 115 | event.stopPropagation(); |
| 116 | |
| 117 | if(event && event.detail) { |
| 118 | this.command(event.detail); |
| 119 | } |
| 120 | }; |
| 121 | |
| 122 | |
| 123 | // Public methods |
| 124 | |
| 125 | /** |
| 126 | * Initialize lightboard slides |
| 127 | * @public |
| 128 | */ |
| 129 | MaterialExtLightboard.prototype.upgradeSlides = function() { |
| 130 | |
| 131 | const addRipple = slide => { |
| 132 | // Use slide frame as ripple container |
| 133 | if(!slide.querySelector(`.${MDL_RIPPLE_CONTAINER}`)) { |
| 134 | const a = slide.querySelector(`.${SLIDE_TABSTOP}`); |
| 135 | if(a) { |
| 136 | const rippleContainer = a; |
| 137 | rippleContainer.classList.add(MDL_RIPPLE_CONTAINER); |
| 138 | rippleContainer.classList.add(MDL_RIPPLE_EFFECT); |
| 139 | const ripple = document.createElement('span'); |
| 140 | ripple.classList.add(MDL_RIPPLE); |
| 141 | rippleContainer.appendChild(ripple); |
| 142 | componentHandler.upgradeElement(rippleContainer, MDL_RIPPLE_COMPONENT); |
| 143 | } |
| 144 | } |
| 145 | }; |
| 146 | |
| 147 | const hasRippleEffect = this.element_.classList.contains(MDL_RIPPLE_EFFECT); |
| 148 | |
| 149 | [...this.element_.querySelectorAll(`.${SLIDE}`)].forEach( slide => { |
| 150 | |
| 151 | slide.setAttribute('role', SLIDE_ROLE); |
| 152 | |
| 153 | if(!slide.querySelector('a')) { |
| 154 | slide.setAttribute('tabindex', '0'); |
| 155 | } |
| 156 | if(hasRippleEffect) { |
| 157 | addRipple(slide); |
| 158 | } |
| 159 | }); |
| 160 | }; |
| 161 | MaterialExtLightboard.prototype['upgradeSlides'] = MaterialExtLightboard.prototype.upgradeSlides; |
| 162 | |
| 163 | |
| 164 | /** |
| 165 | * Execute command |
| 166 | * @param detail |
| 167 | * @public |
| 168 | */ |
| 169 | MaterialExtLightboard.prototype.command = function( detail ) { |
| 170 | |
| 171 | const firstSlide = () => { |
| 172 | return this.element_.querySelector(`.${SLIDE}:first-child`); |
| 173 | }; |
| 174 | |
| 175 | const lastSlide = () => { |
| 176 | return this.element_.querySelector(`.${SLIDE}:last-child`); |
| 177 | }; |
| 178 | |
| 179 | const nextSlide = () => { |
| 180 | const slide = this.element_.querySelector(`.${SLIDE}[aria-selected="true"]`).nextElementSibling; |
| 181 | return slide ? slide : firstSlide(); |
| 182 | }; |
| 183 | |
| 184 | const prevSlide = () => { |
| 185 | const slide = this.element_.querySelector(`.${SLIDE}[aria-selected="true"]`).previousElementSibling; |
| 186 | return slide ? slide : lastSlide(); |
| 187 | }; |
| 188 | |
| 189 | if(detail && detail.action) { |
| 190 | |
| 191 | const { action, target } = detail; |
| 192 | |
| 193 | let slide; |
| 194 | switch (action.toLowerCase()) { |
| 195 | case 'select': |
| 196 | slide = getSlide(target); |
| 197 | this.dispatchSelectEvent_(slide); |
| 198 | break; |
| 199 | case 'first': |
| 200 | slide = firstSlide(); |
| 201 | break; |
| 202 | case 'next': |
| 203 | slide = nextSlide(); |
| 204 | break; |
| 205 | case 'prev': |
| 206 | slide = prevSlide(); |
| 207 | break; |
| 208 | case 'last': |
| 209 | slide = lastSlide(); |
| 210 | break; |
| 211 | case 'upgrade': |
| 212 | this.upgradeSlides(); |
| 213 | break; |
| 214 | default: |
| 215 | throw new Error(`Unknown action "${action}". Action must be one of "first", "next", "prev", "last", "select" or "upgrade"`); |
| 216 | } |
| 217 | |
| 218 | if (slide) { |
| 219 | const a = slide.querySelector('a'); |
| 220 | if (a) { |
| 221 | a.focus(); |
| 222 | } |
| 223 | else { |
| 224 | slide.focus(); |
| 225 | } |
| 226 | |
| 227 | // Workaround for JSDom testing: |
| 228 | // In JsDom 'element.focus()' does not trigger any focus event |
| 229 | if(!slide.hasAttribute('aria-selected')) { |
| 230 | this.selectSlide_(slide); |
| 231 | } |
| 232 | |
| 233 | } |
| 234 | } |
| 235 | }; |
| 236 | MaterialExtLightboard.prototype['command'] = MaterialExtLightboard.prototype.command; |
| 237 | |
| 238 | |
| 239 | /** |
| 240 | * Initialize component |
| 241 | */ |
| 242 | MaterialExtLightboard.prototype.init = function() { |
| 243 | |
| 244 | const keydownHandler = event => { |
| 245 | |
| 246 | if(event.target !== this.element_) { |
| 247 | let action; |
| 248 | let target; |
| 249 | switch (event.keyCode) { |
| 250 | case VK_HOME: |
| 251 | action = 'first'; |
| 252 | break; |
| 253 | case VK_END: |
| 254 | action = 'last'; |
| 255 | break; |
| 256 | case VK_ARROW_UP: |
| 257 | case VK_ARROW_LEFT: |
| 258 | action = 'prev'; |
| 259 | break; |
| 260 | case VK_ARROW_DOWN: |
| 261 | case VK_ARROW_RIGHT: |
| 262 | action = 'next'; |
| 263 | break; |
| 264 | case VK_ENTER: |
| 265 | case VK_SPACE: |
| 266 | action = 'select'; |
| 267 | target = event.target; |
| 268 | break; |
| 269 | } |
| 270 | if(action) { |
| 271 | event.preventDefault(); |
| 272 | event.stopPropagation(); |
| 273 | this.command( { action: action, target: target } ); |
| 274 | } |
| 275 | } |
| 276 | }; |
| 277 | |
| 278 | const clickHandler = event => { |
| 279 | event.preventDefault(); |
| 280 | event.stopPropagation(); |
| 281 | |
| 282 | if(event.target !== this.element_) { |
| 283 | this.command( { action: 'select', target: event.target } ); |
| 284 | } |
| 285 | }; |
| 286 | |
| 287 | const focusHandler = event => { |
| 288 | event.preventDefault(); |
| 289 | event.stopPropagation(); |
| 290 | |
| 291 | if(event.target !== this.element_) { |
| 292 | this.selectSlide_(event.target); |
| 293 | } |
| 294 | }; |
| 295 | |
| 296 | |
| 297 | if (this.element_) { |
| 298 | this.element_.setAttribute('role', LIGHTBOARD_ROLE); |
| 299 | |
| 300 | if (this.element_.classList.contains(MDL_RIPPLE_EFFECT)) { |
| 301 | this.element_.classList.add(MDL_RIPPLE_EFFECT_IGNORE_EVENTS); |
| 302 | } |
| 303 | |
| 304 | // Remove listeners, just in case ... |
| 305 | this.element_.removeEventListener('command', this.commandHandler_); |
| 306 | this.element_.removeEventListener('keydown', keydownHandler); |
| 307 | this.element_.removeEventListener('click', clickHandler); |
| 308 | this.element_.removeEventListener('focus', focusHandler); |
| 309 | |
| 310 | this.element_.addEventListener('command', this.commandHandler_.bind(this), false); |
| 311 | this.element_.addEventListener('keydown', keydownHandler, true); |
| 312 | this.element_.addEventListener('click', clickHandler, true); |
| 313 | this.element_.addEventListener('focus', focusHandler, true); |
| 314 | |
| 315 | this.upgradeSlides(); |
| 316 | |
| 317 | this.element_.classList.add(IS_UPGRADED); |
| 318 | } |
| 319 | }; |
| 320 | |
| 321 | // The component registers itself. It can assume componentHandler is available |
| 322 | // in the global scope. |
| 323 | /* eslint no-undef: 0 */ |
| 324 | /* jshint undef:false */ |
| 325 | componentHandler.register({ |
| 326 | constructor: MaterialExtLightboard, |
| 327 | classAsString: 'MaterialExtLightboard', |
| 328 | cssClass: 'mdlext-js-lightboard', |
| 329 | widget: true |
| 330 | }); |
| 331 | |
| 332 | })(); |