Hacer un Polyfill completo para el elemento de detalles HTML5
- Las soluciones existentes están incompletas
- Soporte de contenido futuro
- Implementación del summarycomportamiento
- summaryCasos de borde de elementos
- Apoyo a openla propiedad
- Fix For Infinite Recursion In IE 8
- Polyfill For The open Attribute
- Implementación final
- Notas sobre el estilo
- Poniendolo todo junto
Desarrollar un polyfill no es el desafío más fácil. Por otro lado, la solución se puede utilizar durante un tiempo relativamente largo: los estándares no cambian con frecuencia y se han discutido extensamente entre bastidores. Además, todos usan el mismo idioma y se conectan con las mismas API, lo cual es genial. Este es un artículo bastante técnico y, aunque Maksim Chemerisuk intentará minimizar los fragmentos de código, este artículo todavía contiene bastantes de ellos. ¡Asi que preparate!
HTML5 introdujo un montón de etiquetas nuevas, una de las cuales es details
. Este elemento es una solución para un componente común de la interfaz de usuario: un bloque plegable. Casi todos los marcos, incluidos Bootstrap y jQuery UI, tienen su propio complemento para una solución similar, pero ninguno se ajusta a la especificación HTML5, probablemente porque la mayoría existía mucho antes de que details
se especificaran y, por lo tanto, representan enfoques diferentes. Un elemento estándar permite que todos utilicen el mismo marcado para un tipo particular de contenido. Es por eso que tiene sentido crear un polyfill robusto .
Descargo de responsabilidad : este es un artículo bastante técnico y, aunque he intentado minimizar los fragmentos de código, el artículo todavía contiene bastantes de ellos. ¡Asi que preparate!
Las soluciones existentes están incompletas
No soy la primera persona que intenta implementar un polyfill de este tipo. Desafortunadamente, todas las demás soluciones presentan uno u otro problema:
- No hay soporte para contenido futuro El soporte para contenido futuro es extremadamente valioso para aplicaciones de una sola página. Sin él, tendría que invocar la función de inicialización cada vez que agregue contenido a la página. Básicamente, un desarrollador quiere poder acceder
details
al DOM y terminar con él, y no tener que jugar con JavaScript para ponerlo en marcha. toggle
Falta el evento Este evento es una notificación de que undetails
elemento ha cambiado deopen
estado. Idealmente, debería ser un evento DOM básico.
En este artículo usaremos Better-dom para simplificar las cosas. La razón principal es la función de extensiones en vivo , que resuelve el problema de invocar la función de inicialización para contenido dinámico. (Para obtener más información, lea mi artículo detallado sobre extensiones en vivo ). Además, Better-dom equipa las extensiones en vivo con un conjunto de herramientas que (todavía) no existen en el DOM básico pero que resultan útiles al implementar un polyfill como este.
Vea la demostración en vivo .
Echemos un vistazo más de cerca a todos los obstáculos que tenemos que superar para que esté details
disponible en navegadores que no lo admiten.
Soporte de contenido futuro
Para comenzar, necesitamos declarar una extensión activa para el “details”
selector. ¿Qué pasa si el navegador ya admite el elemento de forma nativa? Luego necesitaremos agregar alguna detección de funciones. Esto es fácil con el segundo argumento opcional condition
, que evita que la lógica se ejecute si su valor es igual a false
:
// Invoke extension only if there is no native supportvar open = DOM.create("details").get("open");DOM.extend("details", typeof open !== "boolean", { constructor: function() { console.log("initialize details…"); }});
Como puede ver, estamos intentando detectar el soporte nativo comprobando la open
propiedad, que obviamente solo existe en navegadores que reconocen details
.
Lo que lo DOM.extend
diferencia de una simple llamada document.querySelectorAll
es que la constructor
función también se ejecuta para contenido futuro. Y sí, funciona con cualquier biblioteca para manipular el DOM:
// You can use better-dom…DOM.find("body").append( "detailssummaryTITLE/summarypTEXT/p/details");// = logs "initialize details…"// or any other DOM library, like jQuery…$("body").append( "detailssummaryTITLE/summarypTEXT/p/details");// = logs "initialize details…"// or even vanilla DOM.document.body.insertAdjacentElement("beforeend", "detailssummaryTITLE/summarypTEXT/p/details");// = logs "initialize details…"
En las siguientes secciones, reemplazaremos la console.log
llamada con una implementación real.
Implementación del summarycomportamiento
El details
elemento puede tomar summary
como elemento hijo.
"El primer elemento de resumen secundario de los detalles, si hay uno presente, representa una descripción general de los detalles. Si no hay ningún elemento de resumen secundario, entonces el agente de usuario debe proporcionar su propia leyenda (por ejemplo, "Detalles")".
Agreguemos soporte para mouse. Un clic en el summary
elemento debería alternar el open
atributo en el details
elemento principal. Así es como se ve usando Better-dom:
DOM.extend("details", typeof open !== "boolean", { constructor: function() { this .children("summary:first-child") .forEach(this.doInitSummary); }, doInitSummary: function(summary) { summary.on("click", this.doToggleOpen); }, doToggleOpen: function() { // We’ll cover the open property value later. this.set("open", !this.get("open")); }});
El children
método devuelve una matriz de elementos de JavaScript (no un objeto similar a una matriz como en el DOM básico). Por lo tanto, si no summary
se encuentra, entonces la doInitSummary
función no se ejecuta. Además, doInitSummary
y doToggleOpen
son funciones privadas , siempre se invocan para el elemento actual. Entonces, podemos pasar this.doInitSummary
a Array#forEach
sin cierres adicionales y todo se ejecutará correctamente allí.
Tener soporte para teclado además de soporte para mouse también es bueno. Pero primero, hagamos summary
un elemento enfocable. Una solución típica es establecer el tabindex
atributo en 0
:
doInitSummary: function(summary) { // Makes summary focusable summary.set("tabindex", 0); …}
Ahora, el usuario que presione la barra espaciadora o la tecla "Entrar" debería cambiar el estado de details
. En Better-dom, no hay acceso directo al objeto del evento. En su lugar, necesitamos declarar qué propiedades tomar usando un argumento de matriz adicional:
doInitSummary: function(summary) { … summary.on("keydown", ["which"], this.onKeyDown);}
Tenga en cuenta que podemos reutilizar la doToggleOpen
función existente; para un keydown
evento, simplemente realiza una verificación adicional en el primer argumento. Para el controlador de eventos de clic, su valor siempre es igual a undefined
y el resultado será este:
doInitSummary: function(summary) { summary .set("tabindex", 0) .on("click", this.doToggleOpen) .on("keydown", ["which"], this.doToggleOpen);},doToggleOpen: function(key) { if (!key || key === 13 || key === 32) { this.set("open", !this.get("open")); // Cancel form submission on the ENTER key. return false; }}
Ahora tenemos un details
elemento accesible con el mouse y el teclado.
summaryCasos de borde de elementos
El summary
elemento introduce varios casos extremos que debemos tener en cuenta:
1. ¿Cuándo summaryes un niño pero no el primer hijo?
Los proveedores de navegadores han intentado corregir este tipo de marcas no válidas moviéndose summary
visualmente a la posición del primer hijo, incluso cuando el elemento no está en esa posición en el flujo del DOM. Estaba confundido por tal comportamiento, así que le pedí una aclaración al W3C . El W3C confirmó que summary
debe ser el primer hijo de details
. Si verifica el marcado en la captura de pantalla anterior en Nu Markup Checker , fallará con el siguiente mensaje de error:
"Error: el resumen del elemento no se permite como hijo de los detalles del elemento en este contexto. […] Contextos en los que se puede utilizar el resumen del elemento: como primer hijo de un elemento de detalles".
Mi enfoque es mover el summary
elemento a la posición del primer hijo. En otras palabras, el polyfill corrige el marcado no válido por usted:
doInitSummary: function(summary) { // Make sure that summary is the first child if (this.child(0) !== summary) { this.prepend(summary); } …}
2. Cuando el summaryelemento no está presente
Como puede ver en la captura de pantalla anterior, los proveedores de navegadores insertan "Detalles" como leyenda summary
en este caso. El marcado permanece intacto. Desafortunadamente, no podemos lograr lo mismo sin acceder al DOM oculto , que lamentablemente tiene un soporte débil en la actualidad. Aún así, podemos configurar summary
manualmente para cumplir con los estándares:
constructor: function() { … var summaries = this.children("summary"); // If no child summary element is present, then the // user agent should provide its own legend (e.g. "Details"). this.doInitSummary( summaries[0] || DOM.create("summary`Details`"));}
Apoyo a openla propiedad
If you try the code below in browsers that support details
natively and in others that don’t, you’ll get different results:
details.open = true;// details changes state in Chrome and Safaridetails.open = false;// details state changes back in Chrome and Safari
In Chrome and Safari, changing the value of open
triggers the addition or removal of the attribute. Other browsers do not respond to this because they do not support the open
property on the details
element.
Properties are different from simple values. They have a pair of getter and setter functions that are invoked every time you read or assign a new value to the field. And JavaScript has had an API to declare properties since version 1.5.
The good news is that one old browser we are going to use with our polyfill, Internet Explorer (IE) 8, has partial support for the Object.defineProperty
function. The limitation is that the function works only on DOM elements. But that is exactly what we need, right?
There is a problem, though. If you try to set an attribute with the same name in the setter function in IE 8, then the browser will stack with infinite recursion and crashes. In old versions of IE, changing an attribute will trigger the change of an appropriate property and vice versa:
Object.defineProperty(element, "foo", { … set: function(value) { // The line below triggers infinite recursion in IE 8. this.setAttribute("foo", value); }});
So you can’t modify the property without changing an attribute there. This limitation has prevented developers from using the Object.defineProperty
for quite a long time.
The good news is that I’ve found a solution.
Fix For Infinite Recursion In IE 8
Before describing the solution, I’d like to give some background on one feature of the HTML and CSS parser in browsers. In case you weren’t aware, these parsers are case-insensitive. For example, the rules below will produce the same result (i.e. a base red for the text on the page):
body { color: red; }/* The rule below will produce the same result. */BODY { color: red; }
The same goes for attributes:
el.setAttribute("foo", "1");el.setAttribute("FOO", "2");el.getAttribute("foo"); // = "2"el.getAttribute("FOO"); // = "2"
Moreover, you can’t have uppercased and lowercased attributes with the same name. But you can have both on a JavaScript object, because JavaScript is case-sensitive:
var obj = {foo: "1", FOO: "2"};obj.foo; // = "1"obj.FOO; // = "2"
Some time ago, I found that IE 8 supports the deprecated legacy argument lFlags
for attribute methods, which allows you to change attributes in a case-sensitive manner:
lFlags
[in, optional]
- Type: Integer
- Integer that specifies whether to use a case-sensitive search to locate the attribute.
Remember that the infinite recursion happens in IE 8 because the browser is trying to update the attribute with the same name and therefore triggers the setter function over and over again. What if we use the lFlags
argument to get and set the uppercased attribute value:
// Defining the "foo" property but using the "FOO" attributeObject.defineProperty(element, "foo", { get: function() { return this.getAttribute("FOO", 1); }, set: function(value) { // No infinite recursion! this.setAttribute("FOO", value, 1); }});
As you might expect, IE 8 updates the uppercased field FOO
on the JavaScript object, and the setter function does not trigger a recursion. Moreover, the uppercased attributes work with CSS too — as we stated in the beginning, that parser is case-insensitive.
Polyfill For The open Attribute
Ahora podemos definir una open
propiedad que funcione en todos los navegadores:
var attrName = document.addEventListener ? "open" : "OPEN";Object.defineProperty(details, "open", { get: function() { var attrValue = this.getAttribute(attrName, 1); attrValue = String(attrValue).toLowerCase(); // Handle boolean attribute value return attrValue === "" || attrValue === "open"; } set: function(value) { if (this.open !== value) { console.log("firing toggle event"); } if (value) { this.setAttribute(attrName, "", 1); } else { this.removeAttribute(attrName, 1); } }});
Comprueba cómo funciona:
details.open = true;// = logs "firing toggle event"details.hasAttribute("open"); // = truedetails.open = false;// = logs "firing toggle event"details.hasAttribute("open"); // = false
¡Excelente! Ahora hagamos llamadas similares, pero esta vez usando *Attribute
métodos:
details.setAttribute("open", "");// = silence, but fires toggle event in Chrome and Safaridetails.removeAttribute("open");// = silence, but fires toggle event in Chrome and Safari
La razón de tal comportamiento es que la relación entre la open
propiedad y el atributo debe ser bidireccional . Cada vez que se modifica el atributo, la open
propiedad debe reflejar el cambio y viceversa.
La solución más simple para varios navegadores que he encontrado para este problema es anular los métodos de atributo en el elemento de destino e invocar a los configuradores manualmente. Esto evita errores y la penalización de rendimiento de legados propertychange
y DOMAttrModified
eventos. Los navegadores modernos admitenMutationObservers
, pero eso no cubre el alcance de nuestro navegador.
Implementación final
Obviamente, seguir todos los pasos anteriores al definir un nuevo atributo para un elemento DOM no tendría sentido. Necesitamos una función de utilidad que oculte las peculiaridades y la complejidad de varios navegadores. Agregué una función de este tipo, llamada defineAttribute
, en Better-dom.
El primer argumento es el nombre de la propiedad o atributo y el segundo es el objeto get
and set
. La función getter toma el valor del atributo como primer argumento. La función de establecimiento acepta el valor de la propiedad y la declaración devuelta se utiliza para actualizar el atributo. Esta sintaxis nos permite ocultar el truco para IE 8 donde se usa un nombre de atributo en mayúsculas detrás de escena:
constructor: function() { … this.defineAttribute("open", { get: this.doGetOpen, set: this.doSetOpen });},doGetOpen: function(attrValue) { attrValue = String(attrValue).toLowerCase(); return attrValue === "" || attrValue === "open";},doSetOpen: function(propValue) { if (this.get("open") !== propValue) { this.fire("toggle"); } // Adding or removing boolean attribute "open" return propValue ? "" : null;}
Tener un verdadero polirelleno para el open
atributo simplifica nuestra manipulación del details
estado del elemento. Nuevamente, esta API es independiente del marco :
// You can use better-dom…DOM.find("details").set("open", false);// or any other DOM library, like jQuery…$("details").prop("open", true);// or even vanilla DOM.document.querySelector("details").open = false;
Notas sobre el estilo
La parte CSS del polyfill es más simple. Tiene algunas reglas básicas de estilo:
summary:first-child ~ * { display: none;}details[open] * { display: block;}/* Hide native indicator and use pseudo-element instead */summary::-webkit-details-marker { display: none;}
No quería introducir ningún elemento adicional en el marcado, por lo que la opción obvia es darle estilo al ::before
pseudoelemento. Este pseudoelemento se utiliza para indicar el estado actual de details
(según esté abierto o no). Pero IE 8 tiene algunas peculiaridades, como siempre, concretamente, la actualización del estado del pseudoelemento. Conseguí que funcionara correctamente solo cambiando el content
valor de la propiedad:
details:before { content: '25BA'; …}details[open]:before { content: '25BC';}
Para otros navegadores, el truco del borde cero dibujará un triángulo CSS independiente de la fuente. Con una sintaxis de dos puntos para el ::before
pseudoelemento, podemos aplicar reglas a IE 9 y superiores:
details::before { content: ’; width: 0; height: 0; border: solid transparent; border-left-color: inherit; border-width: 0.25em 0.5em; … transform: rotate(0deg) scale(1.5);}details[open]::before { content: ’; transform: rotate(90deg) scale(1.5);}
La mejora final es una pequeña transición en el triángulo. Desafortunadamente, Safari no lo aplica por alguna razón (quizás un error), pero se degrada bien al ignorar la transición por completo:
details::before { … transition: transform 0.15s ease-out;}
Poniendolo todo junto
Hace un tiempo comencé a usar transpiladores en mis proyectos y son geniales. Los transpiladores mejoran los archivos fuente. Incluso puedes codificar en un lenguaje completamente diferente, como CoffeeScript en lugar de JavaScript o LESS en lugar de CSS, etc. Sin embargo, mi intención al usarlos es disminuir el ruido innecesario en el código fuente y aprender nuevas funciones en el futuro cercano. Es por eso que los transpiladores no van en contra de ningún estándar en mis proyectos: solo estoy usando algunas cosas adicionales de ECMAScript 6 (ES6) y postprocesadores CSS ( siendo Autoprefixer el principal).
Además, hablando de agrupación, rápidamente descubrí que distribuir *.css
archivos junto con ellos *.js
es un poco molesto. Mientras buscaba una solución, encontré HTML Imports , cuyo objetivo es resolver este tipo de problema en el futuro. En la actualidad, la función tiene un soporte de navegador relativamente débil . Y, francamente, agrupar todo eso en un único archivo HTML no es lo ideal.
Entonces, creé mi propio enfoque para la agrupación: Better-dom tiene una función DOM.importStyles
que le permite importar reglas CSS en una página web. Esta función ha estado en la biblioteca desde el principio porque DOM.extend
la usa internamente. Como de todos modos uso better-dom y transpilers en mi código, creé una tarea simple de trago:
gulp.task("compile", ["lint"], function() { var jsFilter = filter("*.js"); var cssFilter = filter("*.css"); return gulp.src(["src/*.js", "src/*.css"]) .pipe(cssFilter) .pipe(postcss([autoprefixer, csswring, …])) // need to escape some symbols .pipe(replace(/\|"/g, "\$")) // and convert CSS rules into JavaScript function calls .pipe(replace(/([^{]+){([^}]+)}/g, "DOM.importStyles("$1""
Deja un comentario