Creación de un componente web retro que se puede arrastrar con iluminación

En el artículo de hoy, Andrico Karoulla explica cómo crear un efecto de arrastre interesante escuchando los eventos de arrastre y escribiendo alguna lógica personalizada dentro de los controladores.
En los años 90, mi primer sistema operativo fue Windows. Ahora, en la década de 2020, trabajo principalmente en la creación de aplicaciones web utilizando el navegador. Con el paso de los años, el navegador se ha transformado en una herramienta maravillosa y poderosa que admite un amplio mundo de aplicaciones enriquecidas. Muchas de estas aplicaciones , con sus complejas interfaces y su amplitud de capacidades, harían sonrojar incluso a los programas más resistentes del cambio de milenio.
Las funciones nativas del navegador, como los componentes web, están siendo adoptadas y utilizadas en toda la web por empresas multinacionales y desarrolladores individuales por igual.
En caso de que se pregunte si alguien está usando componentes web:
- GitHub
- YouTube
- Twitter (tweets incrustados)
- SalesForce
- ING
- Aplicación web Photoshop
- Herramientas de desarrollo de Chrome
- La interfaz de usuario completa de Firefox
- Cliente web Apple Music– Danny Moerkerke (@dannymoerkerke) 5 de agosto de 2022
Entonces, ¿por qué no adoptar la tecnología del presente rindiendo homenaje a las interfaces del pasado?
En este artículo, espero enseñarte precisamente eso replicando el icónico efecto de ventana rota .
Usaremos componentes web , el modelo de componentes nativo del navegador, para construir esta interfaz. También usaremos la biblioteca Lit , que simplifica las API de los componentes web nativos.
Muchos de los conceptos de los que hablo aquí son lecciones que aprendí al crear A2k , una biblioteca de interfaz de usuario diseñada para ayudarte a crear una interfaz de usuario retro con herramientas modernas.
En este artículo, cubriremos:
- los conceptos básicos de la creación de componentes web utilizando Lit;
- cómo personalizar fácilmente el comportamiento de su componente utilizando las herramientas integradas de Lit;
- cómo encapsular funcionalidades reutilizables;
- cómo enviar y responder a eventos utilizando métodos avanzados de flujo de datos.
Vale la pena conocer HTML, CSS y algo de JavaScript básico para seguir este tutorial, pero no se requieren conocimientos específicos del marco.
Empezando
Puedes seguir el permiso en el navegador usando StackBlitz .
Una vez que StackBlitz termine de configurarse, debería ver lo siguiente en la ventana del navegador:
Nota: Si no desea utilizar StackBlitz, puede clonar el repositorio y ejecutar las instrucciones dentro del README.md
archivo. También puede utilizar Lit VSCode para resaltar la sintaxis y las funciones.
A continuación, abra el proyecto en el editor de su elección. Echemos un vistazo rápido para ver cómo se ve nuestro código de inicio.
index.html
Tenemos un archivo HTML muy básico que hace poco más que importar algo de CSS y un archivo JavaScript.
Es posible que también hayas visto un elemento nuevo, el a2k-window
elemento. No habrás visto esto antes porque este es el elemento personalizado que construiremos nosotros mismos. Como todavía no hemos creado ni registrado este componente, el navegador volverá a mostrar el contenido HTML interno.
Los diversos .jsarchivos
Agregué un pequeño texto estándar para algunos de los componentes y funciones, pero completaremos los vacíos a lo largo de este(s) artículo(s). Importé todo el código propio y de terceros necesario que usaremos a lo largo de este artículo.
Bonificación: fuentes
¡También agregué algunas fuentes retro para divertirme! Es una maravillosa fuente inspirada en MS-2000 creada por Lou . Puedes descargarlo y usarlo en tus propios proyectos si buscas inyectar un poco de sabor milenario a tus diseños.
Parte 1: Creación de nuestro primer componente web
Escribir nuestro marcado
Lo primero que queremos hacer es conseguir un elemento de ventana de aspecto convincente. Con solo unas pocas líneas de código, tendremos lo siguiente.
Comencemos saltando a nuestro a2k-window.js
archivo. Escribiremos un pequeño texto estándar para que nuestro componente esté en funcionamiento.
Necesitaremos definir una clase que extienda LitElement
la clase base de Lit. Al extenderse desde LitElement
, nuestra clase obtiene la capacidad de gestionar estados y propiedades reactivos. También necesitamos implementar una render
función en la clase que devuelva el marcado para renderizar.
Una implementación realmente básica de una clase se verá así:
class A2kWindow extends LitElement { render() { return html` div slot/slot /div `; }}
Hay dos cosas que vale la pena señalar:
- Podemos especificar un ID de elemento que luego se encapsula dentro del componente web. Al igual que el documento de nivel superior, no se permiten ID duplicados dentro del mismo componente, pero otros componentes web o elementos DOM externos pueden usar el mismo ID.
- El
slot
elemento es una herramienta útil que puede representar marcas personalizadas transmitidas desde el padre. Para aquellos familiarizados con React, podemos compararlo con un portal de React que representa el lugar donde se configura elchildren
accesorio. Puedes hacer más con él, pero eso está más allá del alcance de este artículo.
Escribir lo anterior no hace que nuestro componente web esté disponible en nuestro HTML. Necesitaremos definir un nuevo elemento personalizado para indicarle al navegador que asocie esta definición con el a2k-window
nombre de la etiqueta. Debajo de nuestra clase de componente, escriba el siguiente código:
customElements.define("a2k-window", A2kWindow);
Ahora volvamos a nuestro navegador. Deberíamos esperar ver nuestro nuevo componente renderizado en la página, pero...
Aunque nuestro componente ha sido renderizado, vemos contenido simple y sin estilo. Sigamos adelante y agreguemos más HTML y CSS:
class A2kWindow extends LitElement { static styles = css` :host { font-family: var(--font-primary); } #window { width: min(80ch, 100%); } #panel { border: var(--border-width) solid var(--color-gray-400); box-shadow: 2px 2px var(--color-black); background-color: var(--color-gray-500); } #draggable { background: linear-gradient( 90deg, var(--color-blue-100) 0%, var(--color-blue-700) 100% ); user-select: none; } #draggable p { font-weight: bold; margin: 0; color: white; padding: 2px 8px; } [data-dragging="idle"] { cursor: grab; } [data-dragging="dragging"] { cursor: grabbing; } `; render() { return html` div div slot/slot /div /div `; }}
Hay un par de cosas que vale la pena señalar en el código anterior:
- Definimos los estilos con alcance para este elemento personalizado a través de la
static styles
propiedad. Debido a cómo funciona la encapsulación de estilos, nuestro componente no se verá afectado por ningún estilo externo. Sin embargo, podemos usar las variables CSS que hemos agregado en nuestrostyles.css
para aplicar estilos desde una fuente externa. - Agregué algunos estilos para elementos DOM que aún no existen, pero los agregaremos pronto.
Una nota sobre los estilos: El estilo en Shadow DOM es un tema demasiado extenso para profundizar en este artículo. Para obtener más información sobre el estilo en Shadow DOM, puede consultar la documentación de Lit.
Si actualiza, debería ver lo siguiente:
Que empieza a parecerse más a nuestro componente web inspirado en Windows.
Consejo profesional: si no ves el navegador, aplica los cambios que esperas. Abra las herramientas de desarrollo del navegador. Es posible que el navegador tenga algunos mensajes de error útiles que le ayudarán a determinar dónde fallan las cosas.
Cómo personalizar nuestro componente web
Nuestro siguiente paso es crear el encabezado para nuestro componente de ventana. Una característica principal de los componentes web son las propiedades de los elementos HTML. En lugar de codificar el contenido de texto del encabezado de nuestra ventana, podemos convertirlo en una entrada de propiedad en el elemento. Podemos usar Lit para hacer que nuestras propiedades sean reactivas , lo que activa los métodos del ciclo de vida cuando se modifican.
Para hacer esto, necesitamos hacer tres cosas:
- Definir las propiedades reactivas,
- Asigne un valor predeterminado,
- Representa el valor de la propiedad reactiva al DOM.
En primer lugar, debemos especificar las propiedades reactivas que queremos habilitar para nuestro componente:
class A2kWindow extends LitElement { static styles = css`...`; static properties = { heading: {}, }; render() {...}}
Haremos esto especificando el properties
objeto estático en nuestra clase. Luego especificamos los nombres de las propiedades que queremos, junto con algunas opciones pasadas como objeto. Las opciones predeterminadas de Lit manejan la conversión de propiedades de cadena de forma predeterminada. Esto significa que no necesitamos aplicar ninguna opción y podemos dejarlo heading
como un objeto vacío.
Nuestro siguiente paso es asignar un valor predeterminado. Haremos esto dentro del método constructor del componente.
class A2kWindow extends LitElement { static styles = css`...`; static properties = {...}; constructor() { super(); this.heading = "Building Retro Web Components with Lit"; } render() {...}}
Nota: ¡ No olvides llamar super()
!
Y finalmente, agreguemos un poco más de marcado y representemos el valor en el DOM:
class A2kWindow extends LitElement { static styles = css`...`; static properties = {...}; constructor() {...} render() { return html` div div div p${this.heading}/p /div slot/slot /div /div `; }}
Una vez hecho esto, volvamos a nuestro navegador y veamos cómo se ve todo:
¡Muy convincente!
Prima
Aplique un encabezado personalizado al a2k-element
archivo index.html
.
Breve respiro
¡Es maravilloso ver con qué facilidad podemos crear una interfaz de usuario de 1998 con primitivas modernas en 2022!
¡Y aún no hemos llegado a las partes divertidas! En las siguientes secciones, veremos el uso de algunos de los conceptos intermedios de Lit para crear una funcionalidad de arrastre de una manera que sea reutilizable en todos los componentes personalizados.
Parte 2: Hacer que nuestro componente se pueda arrastrar
¡Aquí es donde las cosas se ponen un poco complicadas! Nos estamos moviendo hacia un territorio de literatura intermedia, así que no te preocupes si no todo tiene perfecto sentido.
Antes de comenzar a escribir el código, hagamos un resumen rápido de los conceptos con los que jugaremos.
Directivas
Como has visto, al escribir nuestras plantillas HTML en Lit, las escribimos dentro de la html
etiqueta literals. Esto nos permite utilizar JavaScript para alterar el comportamiento de nuestras plantillas. Podemos hacer cosas como evaluar expresiones:
html`p${this.heading}/p`
Podemos devolver plantillas específicas bajo ciertas condiciones:
html`p${this.heading ? this.heading : “Please enter a heading”}/p`
Habrá momentos en los que necesitaremos salir del flujo de renderizado normal del sistema de renderizado de Lit. Es posible que quieras renderizar algo más adelante o ampliar la funcionalidad de la plantilla de Lit. Esto se puede lograr mediante el uso de directivas. Lit tiene un puñado de directivas integradas.
Usaremos la styleMap
directiva, que nos permite aplicar estilos directamente a un elemento a través de un objeto JavaScript. Luego, el objeto se transforma en los estilos en línea del elemento. Esto será útil cuando ajustemos la posición del elemento de nuestra ventana, ya que la posición del elemento es administrada por las propiedades CSS. En resumen, styleMap
gira:
const top = this.top // a variable we could get from our class, a function, or anywherestyleMap({ position: "absolute", left: "100px", top})
en
"position: absolute; top: 50px; left: 100px;"
El uso styleMap
facilita el uso de variables para cambiar estilos.
Controladores
Lit tiene varias formas útiles de componer componentes complejos a partir de fragmentos de código más pequeños y reutilizables.
Una forma es construir componentes a partir de muchos componentes más pequeños. Por ejemplo, un botón de icono que se parece a este:
El marcado puede tener el siguiente marcado:
class IconButton extends LitElement { render() { return html` a2k-button a2k-icon icon="windows-icon"/a2k-icon slot/slot /a2k-button ` }}
En el ejemplo anterior, estamos componiendo nuestro IconButton
a partir de dos componentes web preexistentes.
Otra forma de componer lógica compleja es encapsular estados y comportamientos específicos en una clase. Hacerlo nos permite desacoplar comportamientos específicos de nuestro marcado. Esto se puede hacer mediante el uso de controladores, una forma entre marcos de compartir lógica que puede desencadenar re-renderizaciones en un componente. También tienen la ventaja de conectarse con el ciclo de vida del componente.
Nota: Dado que los controladores son multiframe, se pueden usar en React y Vue con adaptadores pequeños.
Con los controladores, podemos hacer algunas cosas interesantes, como gestionar el estado de arrastre y la posición de su componente anfitrión. Curiosamente, ¡eso es exactamente lo que planeamos hacer!
Si bien un controlador puede parecer complicado, si analizamos su esqueleto, podremos entender qué es y qué hace.
export class DragController { x = 0; y = 0; state = "idle" styles = {...} constructor(host, options) { this.host = host; this.host.addController(this); } hostDisconnected() {...} onDragStart = (pointer, ev) = {...}; onDrag = (_, pointers) = {...};}
Comenzamos inicializando nuestro controlador registrándolo con el componente host y almacenando una referencia al host. En nuestro caso, el elemento anfitrión será nuestro a2k-window
componente.
Una vez que hayamos hecho eso, podemos conectarnos a los métodos del ciclo de vida de nuestro host, como hostConnected
, hostUpdate
, hostUpdated
, hostDisconnected
etc., para ejecutar una lógica específica de arrastre. En nuestro caso, sólo necesitaremos conectarnos hostDisconnected
para fines de limpieza.
Finalmente, podemos agregar nuestros propios métodos y propiedades a nuestro controlador que estarán disponibles para nuestro componente host. Aquí definimos algunos métodos privados que serán llamados durante las acciones de arrastre. También estamos definiendo algunas propiedades a las que nuestro elemento host puede acceder.
Cuando se invocan las funciones onDrag
y onDragStart
, actualizamos nuestra styles
propiedad y solicitamos que nuestro componente host se vuelva a representar. Dado que nuestro componente anfitrión convierte este objeto de estilo en CSS en línea (a través de la styleMap
directiva), nuestro componente aplicará los nuevos estilos.
Si esto suena complicado, es de esperar que este diagrama de flujo visualice mejor el proceso.
Escribiendo nuestro controlador
Posiblemente la parte más técnica del artículo: ¡conectemos nuestro controlador!
Comencemos completando la lógica de inicialización de nuestro controlador:
export class DragController { x = 0; y = 0; state = "idle"; styles = { position: "absolute", top: "0px", left: "0px", }; constructor(host, options) { const { getContainerEl = () = null, getDraggableEl = () = Promise.resolve(null), } = options; this.host = host; this.host.addController(this); this.getContainerEl = getContainerEl; getDraggableEl().then((el) = { if (!el) return; this.draggableEl = el; this.init(); }); } init() {...} hostDisconnected() {...} onDragStart = (pointer) = {...}; onDrag = (_, pointers) = {...};}
La principal diferencia entre este fragmento y el esqueleto anterior es la adición del argumento de opciones. Permitimos que nuestro elemento anfitrión proporcione devoluciones de llamada que nos den acceso a dos elementos diferentes: el contenedor y el elemento arrastrable. Usaremos estos elementos más adelante para calcular los estilos de posición correctos.
Por razones que abordaré más adelante, getDraggableEl
es una promesa que devuelve el elemento arrastrable. Una vez que se resuelve la promesa, almacenamos el elemento en la instancia del controlador y activaremos la función de inicialización, que adjunta los detectores de eventos de arrastre al elemento arrastrable.
init() { this.pointerTracker = new PointerTracker(this.draggableEl, { start: (...args) = { this.onDragStart(...args); this.state = "dragging"; this.host.requestUpdate(); return true; }, move: (...args) = { this.onDrag(...args); }, end: (...args) = { this.state = "idle"; this.host.requestUpdate(); }, });}
Usaremos la PointerTracker
biblioteca para rastrear eventos de puntero fácilmente. Es mucho más agradable usar esta biblioteca que escribir la lógica del modo de entrada cruzada en varios navegadores para admitir eventos de puntero.
PointerTracker
requiere dos argumentos, draggableEl
y un objeto de funciones que actúan como controladores de eventos para los eventos de arrastre:
start
: se invoca cuando se presiona el punterodraggableEl
;move
: se invoca al arrastrardraggableEl
;end
: se invoca cuando soltamos el puntero dedraggableEl
.
Para cada uno, actualizamos el método de arrastrar state
, invocamos la devolución de llamada de nuestro controlador o ambas cosas. Nuestro elemento host utilizará la state
propiedad como atributo de elemento, por lo que activamos this.host.requestUpdate
para garantizar que el host se vuelva a representar.
Al igual que con draggableEl
, asignamos una referencia a la pointerTracker
instancia a nuestro controlador para usarla más adelante.
A continuación, comencemos a agregar lógica a las funciones de la clase. Empezaremos con la onDragStart
función:
onDragStart = (pointer, ev) = { this.cursorPositionX = Math.floor(pointer.pageX); this.cursorPositionY = Math.floor(pointer.pageY);};
Aquí almacenamos la posición actual del cursor, que usaremos en la onDrag
función.
onDrag = (_, pointers) = { this.calculateWindowPosition(pointers[0]);};
Cuando onDrag
se llama a la función, se proporciona una lista de los punteros activos. Dado que solo admitiremos el arrastre de una ventana a la vez, podemos acceder de forma segura al primer elemento de la matriz. Luego lo enviaremos a una función que determina la nueva posición del elemento. Abróchate el cinturón porque es un poco salvaje:
calculateWindowPosition(pointer) { const el = this.draggableEl; const containerEl = this.getContainerEl(); if (!el || !containerEl) return; const oldX = this.x; const oldY = this.y; //JavaScript’s floats can be weird, so we’re flooring these to integers. const parsedTop = Math.floor(pointer.pageX); const parsedLeft = Math.floor(pointer.pageY); //JavaScript’s floats can be weird, so we’re flooring these to integers. const cursorPositionX = Math.floor(pointer.pageX); const cursorPositionY = Math.floor(pointer.pageY); const hasCursorMoved = cursorPositionX !== this.cursorPositionX || cursorPositionY !== this.cursorPositionY; // We only need to calculate the window position if the cursor position has changed. if (hasCursorMoved) { const { bottom, height } = el.getBoundingClientRect(); const { right, width } = containerEl.getBoundingClientRect(); // The difference between the cursor’s previous position and its current position. const xDelta = cursorPositionX - this.cursorPositionX; const yDelta = cursorPositionY - this.cursorPositionY; // The happy path - if the element doesn’t attempt to go beyond the browser’s boundaries. this.x = oldX + xDelta; this.y = oldY + yDelta; const outOfBoundsTop = this.y 0; const outOfBoundsLeft = this.x 0; const outOfBoundsBottom = bottom + yDelta window.innerHeight; const outOfBoundsRight = right + xDelta = window.innerWidth; const isOutOfBounds = outOfBoundsBottom || outOfBoundsLeft || outOfBoundsRight || outOfBoundsTop; // Set the cursor positions for the next time this function is invoked. this.cursorPositionX = cursorPositionX; this.cursorPositionY = cursorPositionY; // Otherwise, we force the window to remain within the browser window. if (outOfBoundsTop) { this.y = 0; } else if (outOfBoundsLeft) { this.x = 0; } else if (outOfBoundsBottom) { this.y = window.innerHeight - height; } else if (outOfBoundsRight) { this.x = Math.floor(window.innerWidth - width); } this.updateElPosition(); // We trigger a lifecycle update. this.host.requestUpdate(); }}updateElPosition(x, y) { this.styles.transform = `translate(${this.x}px, ${this.y}px)`;}
Ciertamente no es el código más bonito, así que hice todo lo posible para anotar el código para aclarar lo que está pasando.
Para resumir:
- Cuando se invoca la función, verificamos que tanto
draggableEl
ycontainerEl
estén disponibles. - Luego accedemos a la posición del elemento y a la posición del cursor.
- Luego calculamos si el cursor se movió. Si no es así, no hacemos nada.
- Establecemos la nueva
x
posicióny
del elemento. - Determinamos si el elemento intenta o no romper los límites de la ventana.
- Si es así, actualizamos la posición
x
oy
para devolver el elemento a los límites de la ventana.
- Si es así, actualizamos la posición
- Actualizamos
this.styles
con las novedadesx
yy
valores. - Luego activamos la función de ciclo de vida de actualización del host, lo que hace que nuestro elemento aplique los estilos.
Revise la función varias veces para asegurarse de estar seguro de lo que hace. Están sucediendo muchas cosas, así que no te preocupes si no se absorbe de inmediato.
La updateElPosition
función es una pequeña ayuda en la clase para aplicar los estilos a la styles
propiedad.
También necesitamos agregar un poco de limpieza para asegurarnos de que dejemos de rastrear si nuestro componente se desconecta mientras lo arrastramos.
hostDisconnected() { if (this.pointerTracker) { this.pointerTracker.stop(); }}
Finalmente, debemos regresar a nuestro a2k-window.js
archivo y hacer tres cosas:
- inicializar el controlador,
- aplicar los estilos de posición,
- rastrear el estado de arrastre.
Así es como se ven estos cambios:
class A2kWindow extends LitElement { static styles = css`...`; static properties = {...}; constructor() {...} drag = new DragController(this, { getContainerEl: () = this.shadowRoot.querySelector("#window"), getDraggableEl: () = this.getDraggableEl(), }); async getDraggableEl() { await this.updateComplete; return this.shadowRoot.querySelector("#draggable"); } render() { return html` div style=${styleMap(this.drag.styles)} div div data-dragging=${this.drag.state} p${this.heading}/p /div slot/slot /div /div `; }}
Estamos usando this.shadowRoot.querySelector(selector)
para consultar nuestro DOM oculto. Esto nos permite al controlador acceder a elementos DOM a través de límites DOM ocultos.
Debido a que planeamos enviar eventos desde nuestro elemento de arrastre, debemos esperar hasta que se haya completado la representación, de ahí la await this.updateComplete
declaración.
Once this is all completed, you should be able to jump back into the browser and drag your component around, like so:
Part 3: Creating The Broken Window Effect
Our component is pretty self-contained, which is great. We could use this window element anywhere on our site and drag it without writing any additional code.
And since we’ve created a reusable controller to handle all of the drag functionality, we can add that behavior to future components like a desktop icon.
Now let’s start building out that cool broken window effect when we drag our component.
We could bake this behavior into the window element itself, but it’s not really useful outside of a specific use case, i.e., making a cool visual effect. Instead, we can get our drag controller to emit an event whenever the onDrag
callback is invoked. This means that anyone using our component can listen to the drag event and do whatever they want.
To create the broken window effect, we’ll need to do two things:
- dispatch and listen to the drag event;
- add the broken window element to the DOM.
Dispatching and listening to events in Lit
Lit has a handful of different ways to handle events. You can add event listeners directly within your templates, like so:
handleClick() { console.log("Clicked");}render() { html`button @click="${this.handleClick}"Click me!/button`}
We’re defining the function that we want to fire on button click and passing it through to the element which will be invoked on click. This is a perfectly viable option, and it’s the approach I’d use if the element and callback are located close together.
As I mentioned earlier, we won’t be baking the broken window behavior into the component, as passing down event handlers through a number of different web components would become cumbersome. Instead, we can leverage the native window event object to have a component dispatch an event and have any of its ancestors listen and respond. Have a look at the following example:
// Event Listenerclass SpecialListener extends LitElement { constructor() { super() this.specialLevel = ''; this.addEventListener('special-click', this.handleSpecialClick) } handleSpecialClick(e) { this.specialLevel = e.detail.specialLevel; } render() { html`div p${this.specialLevel}/p special-button /div` }}// Event Dispatcherclass SpecialButton extends LitElement { handleClick() { const event = new CustomEvent("special-click", { bubbles: true, composed: true, detail: { specialLevel: 'high', }, }); this.dispatchEvent(event); } render() { html`button @click="${this.handleClick}"Click me!/button` }}
Note: Don’t forget to check out the MDN resources if you need a refresher on native DOM Events.
We have two components, a listener and a dispatcher. The listener is a component that adds an event listener to itself. It listens to the special-click
event and outputs the value the event sends through.
Our second component, SpecialButton
, is a descendant of SpecialListener
Deja un comentario