blob: 9a4955928f60f5c12f6e575da7b659f7aaca9ef4 [file] [log] [blame]
Copybara botbe50d492023-11-30 00:16:42 +01001/**
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
28import {
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
44const 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})();