12. Carpeta « js / lib / custom »

Versión para imprimir.

A. js / lib / custom / md-app-bar.js

1
import { ES_APPLE } from "../ES_APPLE.js"
2
import { getAttribute } from "../getAttribute.js"
3
import { querySelector } from "../querySelector.js"
4
5
class MdAppBar extends HTMLElement {
6
7
 getContent() {
8
  return /* HTML */`
9
   <style>
10
11
    :host {
12
     display: flex;
13
     box-sizing: border-box;
14
     align-items: center;
15
     padding: 0 0.25rem;
16
     background-color: var(--md-sys-color-surface);
17
     position: sticky;
18
     z-index: 1;
19
     left: env(titlebar-area-x, 0);
20
     top: env(titlebar-area-y, 0);
21
     height: env(titlebar-area-height, 4rem);
22
     width: env(titlebar-area-width, 100%);
23
    }
24
    
25
    :host(.apple) {
26
     height: env(titlebar-area-height, 3rem);
27
    }
28
29
    :host(.scroll) {
30
     background-color: var(--md-sys-color-surface-container-low);
31
    }
32
33
    #navigation {
34
     flex: 0 0 auto;
35
     overflow: hidden
36
    }
37
38
    #navigation ::slotted(*) {
39
     color: var(--md-sys-color-on-surface);
40
    }
41
42
    #acciones {
43
     margin-left: auto;
44
     flex: 0 0 auto;
45
     overflow: hidden
46
    }
47
48
    :host(.centered) #navigation,
49
    :host(.centered) #acciones {
50
     flex: 0 0 6rem;
51
     overflow: hidden
52
    }
53
54
    #headline::slotted(*) {
55
     -webkit-app-region: drag;
56
     flex: 1 1 auto;
57
     white-space: nowrap;
58
     text-overflow: ellipsis;
59
     overflow: hidden;
60
     font-family: var(--md-sys-typescale-title-large-font);
61
     font-weight: var(--md-sys-typescale-title-large-weight);
62
     font-size: var(--md-sys-typescale-title-large-size);
63
     font-style: var(--md-sys-typescale-title-large-font-style);
64
     letter-spacing: var(--md-sys-typescale-title-large-tracking);
65
     line-height: var(--md-sys-typescale-title-large-line-height);
66
     text-transform: var(--md-sys-typescale-title-large-text-transform);
67
     text-decoration: var(--md-sys-typescale-title-large-text-decoration);
68
     color: var(--md-sys-color-on-surface);
69
    }
70
71
    :host(.centered) #headline::slotted(*) {
72
     flex: 1 1 auto;
73
     text-align: center
74
    }
75
76
   </style>
77
78
   <span id="navigation">
79
    <slot name="navigation"></slot>
80
   </span>
81
   <slot id="headline"></slot>
82
   <span id="acciones">
83
    <slot name="action"></slot>
84
   </span>`
85
 }
86
87
 constructor() {
88
  super()
89
  if (ES_APPLE) {
90
   document.body.classList.add("apple")
91
   document.body.classList.remove("material")
92
  } else {
93
   document.body.classList.add("material")
94
   document.body.classList.remove("apple")
95
  }
96
97
  /**
98
   * @private
99
   * @readonly
100
   */
101
  const shadow = this.attachShadow({ mode: "open" })
102
  shadow.innerHTML = this.getContent()
103
  this._configuraAction = this._configuraAction.bind(this)
104
  /**
105
   * @private
106
   * @type {number}
107
   */
108
  this._posY = 0
109
  /**
110
   * @private
111
   * @type {boolean}
112
   */
113
  this._scrolling = false
114
  /**
115
    * @private
116
    * @type { HTMLSlotElement }
117
    */
118
  this._navigation = querySelector(shadow, '[name="navigation"]')
119
  /**
120
    * @private
121
    * @type { HTMLSlotElement }
122
    */
123
  this._action = querySelector(shadow, '[name="action"]')
124
  /**
125
    * @private
126
    * @type { HTMLHeadingElement | null }
127
    */
128
  this._headline = null
129
  /**
130
    * @private
131
    * @type { HTMLElement | null }
132
    */
133
  this._adicional = null
134
  this._action.addEventListener("slotchange", this._configuraAction)
135
  addEventListener("scroll", () => this._onScroll())
136
  addEventListener("load", () => this.configurOtros())
137
 }
138
139
 connectedCallback() {
140
  this.role = "toolbar"
141
  this._configuraAction()
142
 }
143
144
 configurOtros() {
145
  const idHeadline = getAttribute(this, "headline")
146
  if (idHeadline !== "") {
147
   const headline = document.getElementById(idHeadline)
148
   if (headline instanceof HTMLHeadingElement) {
149
    this._headline = headline
150
    if (this.classList.contains("apple") || this.classList.contains("medium")) {
151
     headline.classList.add("md-headline", "headline-small")
152
    } else {
153
     headline.classList.add("md-headline", "headline-medium")
154
    }
155
   }
156
  }
157
  const idAdicional = getAttribute(this, "adicional")
158
  if (idAdicional !== "") {
159
   this._adicional = document.getElementById(idAdicional)
160
   if (this._adicional !== null) {
161
    if (this.classList.contains("apple")) {
162
     this._adicional.style.top = "env(titlebar-area-height, 3rem)"
163
    } else {
164
     this._adicional.style.top = "env(titlebar-area-height, 4rem)"
165
    }
166
   }
167
  }
168
 }
169
170
 _configuraAction() {
171
  const assignedElements = this._action.assignedElements()
172
  if (this.isConnected) {
173
   if (ES_APPLE) {
174
    this.classList.add("apple")
175
    this.classList.remove("material")
176
   } else {
177
    this.classList.add("material")
178
    this.classList.remove("apple")
179
   }
180
   if (this.classList.contains("centered")) {
181
    this.classList.remove("centrado")
182
    this.classList.remove("justificado")
183
   } else {
184
    if (ES_APPLE && assignedElements.length <= 1) {
185
     this.classList.add("centrado")
186
     this.classList.remove("justificado")
187
    } else {
188
     this.classList.add("justificado")
189
     this.classList.remove("centrado")
190
    }
191
   }
192
  }
193
 }
194
195
 /** @private */
196
 _onScroll() {
197
  this._posY = scrollY
198
  if (!this._scrolling) {
199
   requestAnimationFrame(() => this._avanza())
200
  }
201
  this._scrolling = true
202
 }
203
204
 /** @private */
205
 _avanza() {
206
  if (this._posY === 0) {
207
   this.classList.remove("scroll")
208
   if (this._headline !== null) {
209
    if (this._adicional === null) {
210
     this._headline.classList.remove("scroll")
211
    } else {
212
     this._headline.classList.remove("scroll-adicional")
213
    }
214
   }
215
   if (this._adicional !== null) {
216
    this._adicional.classList.remove("scroll")
217
   }
218
  } else {
219
   this.classList.add("scroll")
220
   if (this._headline !== null) {
221
    if (this._adicional === null) {
222
     this._headline.classList.add("scroll")
223
    } else {
224
     this._headline.classList.add("scroll-adicional")
225
    }
226
   }
227
   if (this._adicional !== null) {
228
    this._adicional.classList.add("scroll")
229
   }
230
  }
231
  this._scrolling = false
232
 }
233
234
}
235
236
customElements.define("md-app-bar", MdAppBar)

B. js / lib / custom / md-options-menu.js

1
import { abreElementoHtml } from "../abreElementoHtml.js"
2
import { cierraElementoHtmo } from "../cierraElementoHtmo.js"
3
import { querySelector } from "../querySelector.js"
4
5
export class MdOptionsMenu extends HTMLElement {
6
7
 getContent() {
8
  return /* HTML */`
9
10
   <style>
11
12
    :host {
13
     position: absolute;
14
    }
15
16
   </style>
17
18
   <slot></slot>`
19
 }
20
21
 constructor() {
22
  super()
23
  const shadow = this.attachShadow({ mode: "open" })
24
  shadow.innerHTML = this.getContent()
25
  this._configuraOpciones = this._configuraOpciones.bind(this)
26
27
  /**
28
   * @private
29
   * @type { HTMLSlotElement }
30
   */
31
  this._slot = querySelector(shadow, "slot")
32
  /**
33
   * @private
34
   * @type { HTMLElement[] }
35
   */
36
  this._opciones = []
37
  this._slot.addEventListener("slotchange", this._configuraOpciones)
38
 }
39
40
 connectedCallback() {
41
  this.classList.add("md-menu")
42
  this.role = "listbox"
43
 }
44
45
 /**
46
  * @returns {readonly Readonly<HTMLElement>[]}
47
  */
48
 get opciones() {
49
  return this._opciones
50
 }
51
52
 get seleccion() {
53
  /** @type { HTMLInputElement | null } */
54
  const seleccionado = this.querySelector(".selected")
55
  return seleccionado === null ? "" : seleccionado.value
56
 }
57
58
 _configuraOpciones() {
59
  /**
60
   * @type {HTMLElement[]}
61
  */
62
  const opciones = []
63
  for (const opcion of this._slot.assignedElements()) {
64
   opcion.role = "option"
65
   if (opcion instanceof HTMLElement) {
66
    opciones.push(opcion)
67
   }
68
  }
69
  this._opciones = opciones
70
 }
71
72
 abre() {
73
  abreElementoHtml(this)
74
 }
75
76
77
 cierra() {
78
  cierraElementoHtmo(this)
79
 }
80
81
 /**
82
  * @param {string} value
83
  */
84
 muestraValue(value) {
85
  let texto = ""
86
  for (const opcion of this._opciones) {
87
   if (opcion.dataset.value === value) {
88
    opcion.classList.add("selected")
89
    let textContent = opcion.textContent
90
    if (texto === "" && textContent !== null) {
91
     textContent = textContent.trim()
92
     if (textContent !== "") {
93
      texto = textContent
94
     }
95
    }
96
   } else {
97
    opcion.classList.remove("selected")
98
   }
99
  }
100
  return texto
101
 }
102
103
}
104
105
customElements.define("md-options-menu", MdOptionsMenu)

C. js / lib / custom / md-select-menu.js

1
import { getAttribute } from "../getAttribute.js"
2
import { querySelector } from "../querySelector.js"
3
import { MdOptionsMenu } from "./md-options-menu.js"
4
5
export class MdSelectMenu extends HTMLElement {
6
7
 static get observedAttributes() {
8
  return ["options", "value", "required"]
9
 }
10
11
 getContent() {
12
  return /* HTML */ `
13
   <link rel="stylesheet" href="/css/material-symbols-outlined.css">
14
15
   <style>
16
    :host {
17
     display: block;
18
     cursor: default;
19
    }
20
21
    output {
22
     display: block;
23
     padding-right: 2rem;
24
     white-space: nowrap;
25
     text-overflow: ellipsis;
26
     overflow: hidden;
27
    }
28
29
    #up {
30
     position: absolute;
31
     bottom: 0.5rem;
32
     right: 0.75rem;
33
     display: none;
34
     color: var(--md-sys-color-on-surface-variant);
35
    }
36
37
    #down {
38
     position: absolute;
39
     bottom: 0.5rem;
40
     right: 0.75rem;
41
     color: var(--md-sys-color-on-surface-variant);
42
    }
43
44
    :host(.open) #up {
45
     display: inline-block;
46
    }
47
48
    :host(.open) #down {
49
     display: none;
50
    }
51
52
    :host(:invalid) #up,
53
    :host(:invalid) #down {
54
     color: var(--md-sys-color-error);
55
    }
56
57
   </style>
58
   <output></output>
59
   <span id="down" class="material-symbols-outlined">
60
    arrow_drop_down
61
   </span>
62
   <span id="up" class="material-symbols-outlined">
63
    arrow_drop_up
64
   </span>`
65
 }
66
67
 constructor() {
68
  super()
69
70
  const shadow = this.attachShadow({ mode: "open" })
71
  shadow.innerHTML = this.getContent()
72
73
  this._alterna = this._alterna.bind(this)
74
  this._onKeyDown = this._onKeyDown.bind(this)
75
  this._cierra = this._cierra.bind(this)
76
  this._clicEnDialogo = this._clicEnDialogo.bind(this)
77
  this.clicExterno = this.clicExterno.bind(this)
78
  this.muestraValue = this.muestraValue.bind(this)
79
80
  /**
81
   * @private
82
   * @type {string}
83
   */
84
  this._customValidity = ""
85
86
  /**
87
   * @private
88
   * @type { HTMLOutputElement }
89
   */
90
  this.output = querySelector(shadow, "output")
91
  /**
92
   * @private
93
   * @type { MdOptionsMenu | null }
94
   */
95
  this._optionsMenu = null
96
  /**
97
   * @protected
98
   * @readonly
99
   */
100
  this._internals = this.attachInternals()
101
  this._internals.role = "select"
102
  addEventListener("load", this.muestraValue)
103
 }
104
105
 connectedCallback() {
106
  this.tabIndex = 0
107
  this.role = "combobox"
108
  this.ariaHasPopup = "listbox"
109
  this.ariaExpanded = "false"
110
  this["aria-controls"] = this.options
111
  this.addEventListener("keydown", this._onKeyDown)
112
  const parentElement = this.parentElement
113
  if (parentElement !== null) {
114
   parentElement.addEventListener("click", this._alterna)
115
  }
116
 }
117
118
 /**
119
  * @param {string} nombreDeAtributo
120
  * @param {string} _valorAnterior
121
  * @param {string} _nuevoValor
122
  */
123
 attributeChangedCallback(nombreDeAtributo, _valorAnterior, _nuevoValor) {
124
  switch (nombreDeAtributo) {
125
   case "options":
126
    this._cambiaOptions()
127
    break
128
   case "value":
129
    this.muestraValue()
130
    break
131
   case "required":
132
    this._checkValidity()
133
    break
134
  }
135
 }
136
137
 get options() {
138
  return getAttribute(this, "options")
139
 }
140
141
 set options(options) {
142
  this.setAttribute("options", options)
143
 }
144
145
 _cambiaOptions() {
146
  if (this._optionsMenu !== null) {
147
   this._optionsMenu = null
148
  }
149
  this["aria-controls"] = this.options
150
 }
151
152
 get required() {
153
  return this.hasAttribute("required")
154
 }
155
156
 set required(required) {
157
  this.toggleAttribute("required", Boolean(required))
158
 }
159
160
 get value() {
161
  return getAttribute(this, "value")
162
 }
163
164
 set value(value) {
165
  this.setAttribute("value", value)
166
 }
167
168
 get name() {
169
  return getAttribute(this, "name")
170
 }
171
172
 set name(name) {
173
  this.setAttribute("name", name)
174
 }
175
176
 muestraValue() {
177
  const value = this.value
178
  this._internals.setFormValue(value)
179
180
  // En un futuro se usará esto en vez de la clase populated.
181
  // if (value === "") {
182
  //  this._internals.states.delete("populated")
183
  // } else {
184
  //  this._internals.states.add("populated")
185
  // }
186
187
  if (this.isConnected) {
188
   if (value === "") {
189
    this.classList.remove("populated")
190
   } else {
191
    this.classList.add("populated")
192
   }
193
   this._checkValidity()
194
   const optionsMenu = this.optionsMenu
195
   if (optionsMenu !== null) {
196
    this.output.value = optionsMenu.muestraValue(value)
197
   }
198
  }
199
 }
200
201
 get form() {
202
  return this._internals && this._internals.form
203
 }
204
205
 get willValidate() {
206
  return this._internals ? this._internals.willValidate : true
207
 }
208
209
 /**
210
  * @param {string} message
211
  */
212
 setCustomValidity(message) {
213
  this._customValidity = message
214
  this._checkValidity()
215
 }
216
217
 /**
218
  * @returns {ValidityState}
219
  */
220
 get validity() {
221
  return this._internals.validity
222
 }
223
224
 checkValidity() {
225
  return this._internals.checkValidity()
226
 }
227
228
 reportValidity() {
229
  return this._internals.reportValidity()
230
 }
231
232
 get validationMessage() {
233
  return this._internals.validationMessage
234
 }
235
 /** @returns {boolean} */
236
 _checkValidity() {
237
  if (this._customValidity !== "") {
238
   this._internals.setValidity({ customError: true }, this._customValidity)
239
   return false
240
  } else if (this.required && this.value === "") {
241
   this._internals.setValidity({ valueMissing: true }, "Seleccione una opción.")
242
   return false
243
  } else {
244
   this._internals.setValidity({})
245
   return true
246
  }
247
 }
248
249
 /** @private */
250
 _alterna() {
251
  if (this.classList.contains("open")) {
252
   this._cierra()
253
  } else {
254
   this._abre()
255
  }
256
 }
257
258
 /** @private */
259
 _abre() {
260
  this.classList.add("open")
261
  const parentElement = this.parentElement
262
  if (parentElement !== null) {
263
   const optionsMenu = this.optionsMenu
264
   if (optionsMenu !== null) {
265
    optionsMenu.style.top = `${parentElement.offsetTop + 58}px`
266
    optionsMenu.style.left = `${parentElement.offsetLeft}px`
267
    optionsMenu.style.width = `${parentElement.offsetWidth}px`
268
    optionsMenu.abre()
269
    this.focus()
270
    optionsMenu.addEventListener("click", this._clicEnDialogo)
271
   }
272
   this.ariaExpanded = "true"
273
   document.addEventListener("click", this.clicExterno)
274
  }
275
 }
276
277
 /** @private */
278
 _cierra() {
279
  this.classList.remove("open")
280
  const optionsMenu = this.optionsMenu
281
  if (optionsMenu !== null) {
282
   optionsMenu.cierra()
283
   optionsMenu.removeEventListener("click", this._clicEnDialogo)
284
  }
285
  this.ariaExpanded = "false"
286
  document.removeEventListener("click", this.clicExterno)
287
  this.dispatchEvent(new Event("input", { bubbles: true }))
288
 }
289
290
 get optionsMenu() {
291
  if (this._optionsMenu === null) {
292
   if (this.options !== "") {
293
    const optionsMenu = document.getElementById(this.options)
294
    if (optionsMenu instanceof MdOptionsMenu) {
295
     this._optionsMenu = optionsMenu
296
    } else {
297
     throw new Error(`Valor incorrecto para options: "${this.options}".`)
298
    }
299
   }
300
  }
301
  return this._optionsMenu
302
 }
303
304
 /** @private */
305
 _avanzaOpcion() {
306
  const i = this._valueIndex
307
  if (i > -1) {
308
   const optionsMenu = this.optionsMenu
309
   if (optionsMenu !== null) {
310
    const opciones = optionsMenu.opciones
311
    if (i < opciones.length - 1) {
312
     this.value = getAttribute(opciones[i + 1], "data-value")
313
    }
314
   }
315
  }
316
 }
317
318
 /** @private */
319
 _retrocedeOpcion() {
320
  const i = this._valueIndex
321
  if (i > -1) {
322
   const optionsMenu = this.optionsMenu
323
   if (optionsMenu !== null) {
324
    const opciones = optionsMenu.opciones
325
    if (i > 0) {
326
     this.value = getAttribute(opciones[i - 1], "data-value")
327
    }
328
   }
329
  }
330
 }
331
332
 /**
333
  * @private
334
  * @returns {number}
335
  */
336
 get _valueIndex() {
337
  const value = this.value
338
  const optionsMenu = this.optionsMenu
339
  return (optionsMenu === null
340
   ? -1
341
   : optionsMenu.opciones.findIndex(opcion => opcion.dataset.value === value))
342
 }
343
344
 /**
345
  * @private
346
  * @param {Event} event
347
  */
348
 _clicEnDialogo(event) {
349
  const target = event.target
350
  const optionsMenu = this.optionsMenu
351
  let value = ""
352
  if (optionsMenu !== null) {
353
   for (const opcion of optionsMenu.opciones) {
354
    if (opcion === target) {
355
     opcion.classList.add("selected")
356
     value = getAttribute(opcion, "data-value")
357
    } else {
358
     opcion.classList.remove("selected")
359
    }
360
   }
361
  }
362
  this.value = value
363
  this._cierra()
364
  this.focus()
365
}
366
367
 /**
368
  * @param {Event} evt
369
  */
370
 clicExterno(evt) {
371
  const target = evt.target
372
  const parentElement = this.parentElement
373
  const optionsMenu = this._optionsMenu
374
  if (this.classList.contains("open")
375
   && target instanceof HTMLElement
376
   && parentElement !== null
377
   && !parentElement.contains(target)
378
   && optionsMenu !== null
379
   && !optionsMenu.contains(target)) {
380
   this._cierra()
381
  }
382
 }
383
384
 /**
385
  * @param { KeyboardEvent } event
386
  */
387
 _onKeyDown(event) {
388
  const key = event.key
389
  const optionsMenu = this._optionsMenu
390
  if (optionsMenu !== null) {
391
   if (optionsMenu.classList.contains("open")) {
392
    if (key === "ArrowDown") {
393
     event.preventDefault()
394
     this._avanzaOpcion()
395
    } else if (key === "ArrowUp") {
396
     event.preventDefault()
397
     this._retrocedeOpcion()
398
    } else if (key === "Escape") {
399
     event.preventDefault()
400
     this._cierra()
401
    } else if (key === " ") {
402
     event.preventDefault()
403
     this._cierra()
404
    } else if (key === "Tab") {
405
     this._cierra()
406
    } else {
407
     event.preventDefault()
408
    }
409
   } else if (key === " ") {
410
    event.preventDefault()
411
    this._abre()
412
   } else if (key === "Tab") {
413
    this._cierra()
414
   } else {
415
    event.preventDefault()
416
   }
417
  }
418
 }
419
420
}
421
422
MdSelectMenu.formAssociated = true
423
424
customElements.define("md-select-menu", MdSelectMenu)