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

1import { getAttribute } from "../getAttribute.js"
2import { querySelector } from "../querySelector.js"
3import { MdOptionsMenu } from "./md-options-menu.js"
4
5export 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="/lib/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
422MdSelectMenu.formAssociated = true
423
424customElements.define("md-select-menu", MdSelectMenu)
skip_previous skip_next