8. public / libmde / md-select-menu.js

1
import { getAttribute } from "../libclienteweb/getAttribute.js"
2
import { querySelector } from "../libclienteweb/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 bounds = this.getBoundingClientRect()
262
   const optionsMenu = this.optionsMenu
263
   if (optionsMenu !== null) {
264
    optionsMenu.style.top = `${ bounds.bottom}px`
265
    optionsMenu.style.left = `${ bounds.left}px`
266
    optionsMenu.style.width = `${ bounds.width}px`
267
    optionsMenu.abre()
268
    this.focus()
269
    optionsMenu.addEventListener("click", this._clicEnDialogo)
270
   }
271
   this.ariaExpanded = "true"
272
   document.addEventListener("click", this.clicExterno)
273
 }
274
275
 /** @private */
276
 _cierra() {
277
  this.classList.remove("open")
278
  const optionsMenu = this.optionsMenu
279
  if (optionsMenu !== null) {
280
   optionsMenu.cierra()
281
   optionsMenu.removeEventListener("click", this._clicEnDialogo)
282
  }
283
  this.ariaExpanded = "false"
284
  document.removeEventListener("click", this.clicExterno)
285
  this.dispatchEvent(new Event("input", { bubbles: true }))
286
 }
287
288
 get optionsMenu() {
289
  if (this._optionsMenu === null) {
290
   if (this.options !== "") {
291
    const optionsMenu = document.getElementById(this.options)
292
    if (optionsMenu instanceof MdOptionsMenu) {
293
     this._optionsMenu = optionsMenu
294
    } else {
295
     throw new Error(`Valor incorrecto para options: "${this.options}".`)
296
    }
297
   }
298
  }
299
  return this._optionsMenu
300
 }
301
302
 /** @private */
303
 _avanzaOpcion() {
304
  const i = this._valueIndex
305
  if (i > -1) {
306
   const optionsMenu = this.optionsMenu
307
   if (optionsMenu !== null) {
308
    const opciones = optionsMenu.opciones
309
    if (i < opciones.length - 1) {
310
     this.value = getAttribute(opciones[i + 1], "data-value")
311
    }
312
   }
313
  }
314
 }
315
316
 /** @private */
317
 _retrocedeOpcion() {
318
  const i = this._valueIndex
319
  if (i > -1) {
320
   const optionsMenu = this.optionsMenu
321
   if (optionsMenu !== null) {
322
    const opciones = optionsMenu.opciones
323
    if (i > 0) {
324
     this.value = getAttribute(opciones[i - 1], "data-value")
325
    }
326
   }
327
  }
328
 }
329
330
 /**
331
  * @private
332
  * @returns {number}
333
  */
334
 get _valueIndex() {
335
  const value = this.value
336
  const optionsMenu = this.optionsMenu
337
  return (optionsMenu === null
338
   ? -1
339
   : optionsMenu.opciones.findIndex(opcion => opcion.dataset.value === value))
340
 }
341
342
 /**
343
  * @private
344
  * @param {Event} event
345
  */
346
 _clicEnDialogo(event) {
347
  const target = event.target
348
  const optionsMenu = this.optionsMenu
349
  let value = ""
350
  if (optionsMenu !== null) {
351
   for (const opcion of optionsMenu.opciones) {
352
    if (opcion === target) {
353
     opcion.classList.add("selected")
354
     value = getAttribute(opcion, "data-value")
355
    } else {
356
     opcion.classList.remove("selected")
357
    }
358
   }
359
  }
360
  this.value = value
361
  this._cierra()
362
  this.focus()
363
}
364
365
 /**
366
  * @param {Event} evt
367
  */
368
 clicExterno(evt) {
369
  const target = evt.target
370
  const parentElement = this.parentElement
371
  const optionsMenu = this._optionsMenu
372
  if (this.classList.contains("open")
373
   && target instanceof HTMLElement
374
   && parentElement !== null
375
   && !parentElement.contains(target)
376
   && optionsMenu !== null
377
   && !optionsMenu.contains(target)) {
378
   this._cierra()
379
  }
380
 }
381
382
 /**
383
  * @param { KeyboardEvent } event
384
  */
385
 _onKeyDown(event) {
386
  const key = event.key
387
  const optionsMenu = this._optionsMenu
388
  if (optionsMenu !== null) {
389
   if (optionsMenu.classList.contains("open")) {
390
    if (key === "ArrowDown") {
391
     event.preventDefault()
392
     this._avanzaOpcion()
393
    } else if (key === "ArrowUp") {
394
     event.preventDefault()
395
     this._retrocedeOpcion()
396
    } else if (key === "Escape") {
397
     event.preventDefault()
398
     this._cierra()
399
    } else if (key === " ") {
400
     event.preventDefault()
401
     this._cierra()
402
    } else if (key === "Tab") {
403
     this._cierra()
404
    } else {
405
     event.preventDefault()
406
    }
407
   } else if (key === " ") {
408
    event.preventDefault()
409
    this._abre()
410
   } else if (key === "Tab") {
411
    this._cierra()
412
   } else {
413
    event.preventDefault()
414
   }
415
  }
416
 }
417
418
}
419
420
MdSelectMenu.formAssociated = true
421
422
customElements.define("md-select-menu", MdSelectMenu)
skip_previous skip_next