Создание совместимых веб-компонентов, которые работают даже с React PlatoBlockchain Data Intelligence. Вертикальный поиск. Ай.

Создание взаимодействующих веб-компонентов, которые работают даже с React

Те из нас, кто занимается веб-разработкой более нескольких лет, вероятно, писали код с использованием более чем одного JavaScript-фреймворка. Со всеми возможными вариантами — React, Svelte, Vue, Angular, Solid — это практически неизбежно. Одна из самых неприятных вещей, с которой нам приходится сталкиваться при работе с разными фреймворками, — это воссоздание всех этих низкоуровневых компонентов пользовательского интерфейса: кнопок, вкладок, раскрывающихся списков и т. д. Что особенно расстраивает, так это то, что мы обычно определяем их в одном фреймворке. , скажем, React, но тогда нужно их переписать, если мы хотим построить что-то в Svelte. Или Вью. Или Солид. И так далее.

Не было бы лучше, если бы мы могли определить эти низкоуровневые компоненты пользовательского интерфейса один раз, независимо от фреймворка, а затем повторно использовать их между фреймворками? Конечно, было бы! И мы можем; веб-компоненты - это путь. Этот пост покажет вам, как это сделать.

На данный момент истории SSR для веб-компонентов немного не хватает. Декларативное теневое DOM (DSD) — это то, как веб-компонент визуализируется на стороне сервера, но на момент написания этой статьи он не интегрирован с вашими любимыми платформами приложений, такими как Next, Remix или SvelteKit. Если это требование для вас, обязательно проверьте последний статус DSD. Но в остальном, если вы не используете SSR, читайте дальше.

Сначала немного контекста

Веб-компоненты — это, по сути, HTML-элементы, которые вы определяете сами, например <yummy-pizza> или что-то еще, с нуля. Они описаны повсюду здесь, в CSS-Tricks (включая обширная серия Калеба Уильямса и один от Джона Рея), но мы кратко рассмотрим этот процесс. По сути, вы определяете класс JavaScript, наследуете его от HTMLElement, а затем определите все свойства, атрибуты и стили веб-компонента и, конечно же, разметку, которую он в конечном итоге будет отображать для ваших пользователей.

Возможность определять пользовательские элементы HTML, которые не привязаны к какому-либо конкретному компоненту, очень интересна. Но эта свобода есть и ограничение. Существование независимо от какой-либо среды JavaScript означает, что вы не можете взаимодействовать с этими средами JavaScript. Подумайте о компоненте React, который извлекает некоторые данные, а затем отображает некоторые из них. другие Компонент React, передавая данные. На самом деле это не будет работать как веб-компонент, поскольку веб-компонент не знает, как отображать компонент React.

Веб-компоненты особенно хороши в качестве листовые компоненты. Компоненты листа это последнее, что нужно визуализировать в дереве компонентов. Это компоненты, которые получают некоторые реквизиты и отображают некоторые UI, Эти не компоненты, находящиеся в середине вашего дерева компонентов, передающие данные, устанавливающие контекст и т. д. — просто кусочки чистой воды. UI это будет выглядеть одинаково, независимо от того, какой JavaScript-фреймворк поддерживает остальную часть приложения.

Веб-компонент, который мы создаем

Вместо того, чтобы создавать что-то скучное (и обычное), например, кнопку, давайте создадим что-то немного другое. В моем загрузка сообщение мы рассмотрели использование размытых предварительных просмотров изображений, чтобы предотвратить перекомпоновку контента и предоставить пользователям достойный пользовательский интерфейс во время загрузки наших изображений. Мы посмотрели, как base64 кодирует размытые, ухудшенные версии наших изображений и показывает это в нашем пользовательском интерфейсе, пока загружается реальное изображение. Мы также рассмотрели создание невероятно компактных размытых превью с помощью инструмента под названием Блурхэш.

В этом посте показано, как создавать эти превью и использовать их в проекте React. Этот пост покажет вам, как использовать эти предварительные просмотры из веб-компонента, чтобы их могли использовать любой Фреймворк JavaScript.

Но нам нужно пройтись, прежде чем мы сможем бежать, поэтому сначала мы пройдемся по чему-нибудь тривиальному и глупому, чтобы увидеть, как именно работают веб-компоненты.

Все в этом посте будет создавать ванильные веб-компоненты без каких-либо инструментов. Это означает, что код будет немного шаблонным, но его будет относительно легко понять. Такие инструменты, как Lit or трафарет предназначены для создания веб-компонентов и могут быть использованы для удаления большей части этого шаблонного кода. Я призываю вас проверить их! Но для этого поста я предпочитаю немного больше шаблонного кода в обмен на то, что мне не нужно будет вводить и учить еще одну зависимость.

Простой компонент счетчика

Давайте создадим классический «Hello World» из компонентов JavaScript: счетчик. Мы отобразим значение и кнопку, которая увеличивает это значение. Просто и скучно, но это позволит нам взглянуть на самый простой веб-компонент.

Чтобы создать веб-компонент, первым шагом является создание класса JavaScript, который наследуется от HTMLElement:

class Counter extends HTMLElement {}

Последний шаг — зарегистрировать веб-компонент, но только если мы его еще не зарегистрировали:

if (!customElements.get("counter-wc")) { customElements.define("counter-wc", Counter);
}

И, конечно же, визуализировать:

<counter-wc></counter-wc>

И все, что между ними, это то, что мы заставляем веб-компонент делать то, что мы хотим. Одним из распространенных методов жизненного цикла является connectedCallback, который срабатывает, когда наш веб-компонент добавляется в DOM. Мы могли бы использовать этот метод для рендеринга любого контента, который нам нужен. Помните, что это класс JS, наследуемый от HTMLElement, значит наш this value — это сам элемент веб-компонента со всеми обычными методами манипулирования DOM, которые вы уже знаете и любите.

В самом простом случае мы могли бы сделать это:

class Counter extends HTMLElement { connectedCallback() { this.innerHTML = "<div style='color: green'>Hey</div>"; }
} if (!customElements.get("counter-wc")) { customElements.define("counter-wc", Counter);
}

…который будет работать просто отлично.

Слово "привет" в зеленом цвете.
Создание взаимодействующих веб-компонентов, которые работают даже с React

Добавление реального контента

Давайте добавим полезный интерактивный контент. Нам нужно <span> для хранения текущего числового значения и <button> для увеличения счетчика. На данный момент мы создадим этот контент в нашем конструкторе и добавим его, когда веб-компонент фактически находится в DOM:

constructor() { super(); const container = document.createElement('div'); this.valSpan = document.createElement('span'); const increment = document.createElement('button'); increment.innerText = 'Increment'; increment.addEventListener('click', () => { this.#value = this.#currentValue + 1; }); container.appendChild(this.valSpan); container.appendChild(document.createElement('br')); container.appendChild(increment); this.container = container;
} connectedCallback() { this.appendChild(this.container); this.update();
}

Если вам действительно не нравится ручное создание DOM, помните, что вы можете установить innerHTMLили даже один раз создать элемент шаблона в качестве статического свойства класса веб-компонента, клонировать его и вставить содержимое для новых экземпляров веб-компонента. Возможно, есть какие-то другие варианты, о которых я не думаю, или вы всегда можете использовать структуру веб-компонентов, например Lit or трафарет. Но в этом посте мы продолжим упрощать.

Двигаясь дальше, нам нужно устанавливаемое свойство класса JavaScript с именем value

#currentValue = 0; set #value(val) { this.#currentValue = val; this.update();
}

Это просто стандартное свойство класса с установщиком, а также второе свойство для хранения значения. Один забавный момент заключается в том, что для этих значений я использую синтаксис свойства частного класса JavaScript. Это означает, что никто за пределами нашего веб-компонента не может касаться этих значений. Это стандартный JavaScript это поддерживается во всех современных браузерах, так что не бойтесь использовать его.

Или не стесняйтесь называть это _value Если вы предпочитаете. И, наконец, наш update Метод:

update() { this.valSpan.innerText = this.#currentValue;
}

Оно работает!

Веб-компонент счетчика.
Создание взаимодействующих веб-компонентов, которые работают даже с React

Очевидно, что это не тот код, который вы хотели бы поддерживать в масштабе. Вот полный рабочий пример если вы хотите посмотреть поближе. Как я уже говорил, такие инструменты, как Lit и Stencil, призваны упростить эту задачу.

Добавляем еще немного функционала

Этот пост не является глубоким погружением в веб-компоненты. Мы не будем охватывать все API и жизненные циклы; мы даже не будем покрывать теневые корни или слоты. На эти темы есть бесконечный контент. Моя цель здесь состоит в том, чтобы предоставить достаточно приличное введение, чтобы вызвать некоторый интерес, наряду с некоторыми полезными указаниями на самом деле. через веб-компоненты с популярными платформами JavaScript, которые вы уже знаете и любите.

С этой целью давайте немного улучшим наш веб-компонент счетчика. Пусть он примет color атрибут, чтобы управлять цветом отображаемого значения. И пусть он также принимает increment свойство, поэтому потребители этого веб-компонента могут увеличивать его на 2, 3, 4 за раз. И чтобы управлять этими изменениями состояния, давайте воспользуемся нашим новым счетчиком в песочнице Svelte — мы немного перейдем к React.

Мы начнем с того же веб-компонента, что и раньше, и добавим атрибут цвета. Чтобы настроить наш веб-компонент для приема атрибута и ответа на него, мы добавляем статический observedAttributes свойство, которое возвращает атрибуты, которые прослушивает наш веб-компонент.

static observedAttributes = ["color"];

Имея это на месте, мы можем добавить attributeChangedCallback метод жизненного цикла, который будет запускаться всякий раз, когда любой из атрибутов, перечисленных в observedAttributes устанавливаются или обновляются.

attributeChangedCallback(name, oldValue, newValue) { if (name === "color") { this.update(); }
}

Теперь мы обновляем наш update способ его фактического использования:

update() { this.valSpan.innerText = this._currentValue; this.valSpan.style.color = this.getAttribute("color") || "black";
}

Наконец, давайте добавим наш increment имущество:

increment = 1;

Простой и скромный.

Использование компонента счетчика в Svelte

Давайте воспользуемся тем, что мы только что сделали. Мы перейдем к нашему компоненту приложения Svelte и добавим что-то вроде этого:

<script> let color = "red";
</script> <style> main { text-align: center; }
</style> <main> <select bind:value={color}> <option value="red">Red</option> <option value="green">Green</option> <option value="blue">Blue</option> </select> <counter-wc color={color}></counter-wc>
</main>

И это работает! Наш счетчик отображает, увеличивает, а раскрывающийся список обновляет цвет. Как видите, мы визуализируем атрибут цвета в нашем шаблоне Svelte, и при изменении значения Svelte выполняет всю работу по вызову setAttribute в нашем базовом экземпляре веб-компонента. Здесь нет ничего особенного: то же самое уже сделано для атрибутов любой HTML-элемент.

Все становится немного интереснее с increment опора Это не атрибут нашего веб-компонента; это реквизит класса веб-компонента. Это означает, что его необходимо установить в экземпляре веб-компонента. Потерпите меня, так как через некоторое время все станет намного проще.

Во-первых, мы добавим несколько переменных в наш компонент Svelte:

let increment = 1;
let wcInstance;

Наш мощный компонент счетчика позволит вам увеличить значение на 1 или на 2:

<button on:click={() => increment = 1}>Increment 1</button>
<button on:click={() => increment = 2}>Increment 2</button>

Но, в теории, нам нужно получить фактический экземпляр нашего веб-компонента. Это то же самое, что мы всегда делаем каждый раз, когда добавляем ref с Реагировать. Со Svelte это просто bind:this директива:

<counter-wc bind:this={wcInstance} color={color}></counter-wc>

Теперь в нашем шаблоне Svelte мы прослушиваем изменения в переменной приращения нашего компонента и устанавливаем базовое свойство веб-компонента.

$: { if (wcInstance) { wcInstance.increment = increment; }
}

Вы можете проверить это на этой живой демонстрации.

Очевидно, мы не хотим делать это для каждого веб-компонента или объекта, которым нам нужно управлять. Было бы неплохо, если бы мы могли просто установить increment прямо в нашем веб-компоненте, в разметке, как мы обычно делаем для свойств компонента, и, знаете, просто работай? Другими словами, было бы неплохо, если бы мы могли удалить все случаи использования wcInstance и вместо этого используйте этот более простой код:

<counter-wc increment={increment} color={color}></counter-wc>

Оказывается, можем. Этот код работает; Svelte берет на себя всю эту работу за нас. Проверьте это в этой демонстрации. Это стандартное поведение практически для всех фреймворков JavaScript.

Так почему же я показал вам ручной способ настройки реквизита веб-компонента? Две причины: полезно понимать, как эти вещи работают, и минуту назад я сказал, что это работает «почти» для всех фреймворков JavaScript. Но есть один фреймворк, который, как ни странно, не поддерживает настройку реквизита веб-компонента, как мы только что видели.

React — другой зверь

Создание совместимых веб-компонентов, которые работают даже с React PlatoBlockchain Data Intelligence. Вертикальный поиск. Ай.
Создание взаимодействующих веб-компонентов, которые работают даже с React

Реагировать. Самый популярный фреймворк JavaScript на планете не поддерживает базовое взаимодействие с веб-компонентами. Это известная проблема, уникальная для React. Интересно, что это на самом деле исправлено в экспериментальной ветке React, но по какой-то причине не было объединено в версию 18. Тем не менее, мы все еще можем отслеживать его ход. И вы можете попробовать это сами с помощью демо.

Решение, конечно, состоит в том, чтобы использовать ref, возьмите экземпляр веб-компонента и вручную установите increment когда это значение изменится. Это выглядит так:

import React, { useState, useRef, useEffect } from 'react';
import './counter-wc'; export default function App() { const [increment, setIncrement] = useState(1); const [color, setColor] = useState('red'); const wcRef = useRef(null); useEffect(() => { wcRef.current.increment = increment; }, [increment]); return ( <div> <div className="increment-container"> <button onClick={() => setIncrement(1)}>Increment by 1</button> <button onClick={() => setIncrement(2)}>Increment by 2</button> </div> <select value={color} onChange={(e) => setColor(e.target.value)}> <option value="red">Red</option> <option value="green">Green</option> <option value="blue">Blue</option> </select> <counter-wc ref={wcRef} increment={increment} color={color}></counter-wc> </div> );
}

Как мы уже говорили, кодирование вручную для каждого свойства веб-компонента просто не масштабируется. Но не все потеряно, потому что у нас есть пара вариантов.

Вариант 1. Используйте атрибуты везде

У нас есть атрибуты. Если вы щелкнули демонстрацию React выше, increment реквизит не работал, но цвет изменился правильно. Разве мы не можем кодировать все атрибутами? К сожалению нет. Значения атрибутов могут быть только строками. Здесь этого достаточно, и с таким подходом мы сможем продвинуться довольно далеко. Цифры как increment могут быть преобразованы в и из строк. Мы могли бы даже JSON преобразовать/разобрать объекты. Но в конечном итоге нам нужно будет передать функцию веб-компоненту, и в этот момент у нас не останется вариантов.

Вариант 2: завернуть

Есть старая поговорка, что любую проблему в информатике можно решить, добавив уровень косвенности (за исключением проблемы слишком большого количества уровней косвенности). Код для установки этих реквизитов довольно предсказуем и прост. Что, если мы спрячем его в библиотеке? Умные люди, стоящие за Lit есть одно решение. Эта библиотека создает для вас новый компонент React после того, как вы предоставите ему веб-компонент, и перечисляет необходимые ему свойства. Хотя я умный, я не поклонник этого подхода.

Вместо однозначного сопоставления веб-компонентов с компонентами React, созданными вручную, я предпочитаю просто one Компонент React, который мы передаем нашему веб-компоненту название тэга к (counter-wc в нашем случае) — вместе со всеми атрибутами и свойствами — и чтобы этот компонент отображал наш веб-компонент, добавьте ref, затем выясните, что такое свойство и что такое атрибут. Это идеальное решение, на мой взгляд. Я не знаю библиотеки, которая делает это, но ее создание должно быть простым. Давайте попробуем!

Это пользования мы ищем:

<WcWrapper wcTag="counter-wc" increment={increment} color={color} />

wcTag имя тега веб-компонента; остальные — это свойства и атрибуты, которые мы хотим передать.

Вот как выглядит моя реализация:

import React, { createElement, useRef, useLayoutEffect, memo } from 'react'; const _WcWrapper = (props) => { const { wcTag, children, ...restProps } = props; const wcRef = useRef(null); useLayoutEffect(() => { const wc = wcRef.current; for (const [key, value] of Object.entries(restProps)) { if (key in wc) { if (wc[key] !== value) { wc[key] = value; } } else { if (wc.getAttribute(key) !== value) { wc.setAttribute(key, value); } } } }); return createElement(wcTag, { ref: wcRef });
}; export const WcWrapper = memo(_WcWrapper);

Самая интересная строка в конце:

return createElement(wcTag, { ref: wcRef });

Вот как мы создаем элемент в React с динамическим именем. На самом деле это то, во что React обычно транспилирует JSX. Все наши элементы div преобразуются в createElement("div") звонки. Обычно нам не нужно вызывать этот API напрямую, но он всегда рядом, когда нам это нужно.

Кроме того, мы хотим запустить эффект макета и пройтись по каждому свойству, которое мы передали нашему компоненту. Мы перебираем их все и проверяем, является ли это свойством с in check, который проверяет объект экземпляра веб-компонента, а также его цепочку прототипов, которая перехватывает любые геттеры/сеттеры, попадающие в прототип класса. Если такого свойства не существует, предполагается, что оно является атрибутом. В любом случае мы устанавливаем его только в том случае, если значение действительно изменилось.

Если вам интересно, почему мы используем useLayoutEffect вместо useEffect, это потому, что мы хотим немедленно запустить эти обновления до того, как наш контент будет отображен. Также обратите внимание, что у нас нет массива зависимостей для нашего useLayoutEffect; это означает, что мы хотим запустить это обновление на каждый рендер. Это может быть рискованно, поскольку React имеет тенденцию к повторному рендерингу. много. Я улучшаю это, завернув все это в React.memo. По сути это современная версия React.PureComponent, что означает, что компонент будет перерисовываться только в том случае, если какой-либо из его фактических реквизитов изменился — и он проверяет, произошло ли это, с помощью простой проверки на равенство.

Единственный риск здесь заключается в том, что если вы передаете свойство объекта, которое вы мутируете напрямую, без повторного назначения, вы не увидите обновлений. Но это крайне не рекомендуется, особенно в сообществе React, так что я бы не стал об этом беспокоиться.

Прежде чем двигаться дальше, я хотел бы высказать одну последнюю вещь. Вы можете быть недовольны тем, как выглядит использование. Опять же, этот компонент используется следующим образом:

<WcWrapper wcTag="counter-wc" increment={increment} color={color} />

В частности, вам может не понравиться передача имени тега веб-компонента в <WcWrapper> компонент и предпочитают вместо этого @lit-labs/react выше, который создает новый отдельный компонент React для каждого веб-компонента. Это совершенно справедливо, и я бы посоветовал вам использовать то, что вам наиболее удобно. Но для меня одно преимущество этого подхода заключается в том, что его легко удалять. Если каким-то чудом React объединит правильную обработку веб-компонентов из своей экспериментальной ветки в main завтра вы сможете изменить приведенный выше код:

<WcWrapper wcTag="counter-wc" increment={increment} color={color} />

…к этому:

<counter-wc ref={wcRef} increment={increment} color={color} />

Возможно, вы могли бы даже написать единственный кодмод, чтобы сделать это везде, а затем удалить <WcWrapper> вообще. На самом деле, зачеркните это: глобальный поиск и замена с помощью RegEx, вероятно, сработают.

Реализация

Я знаю, кажется, что нужно было проделать путь, чтобы добраться сюда. Если вы помните, наша первоначальная цель состояла в том, чтобы взять код предварительного просмотра изображения, который мы рассматривали в моем загрузка сообщениеи переместите его в веб-компонент, чтобы его можно было использовать в любой среде JavaScript. Отсутствие в React надлежащего взаимодействия добавило много деталей. Но теперь, когда у нас есть приличное представление о том, как создать веб-компонент и использовать его, реализация будет почти антиклиматической.

Я оставлю здесь весь веб-компонент и выделю некоторые интересные фрагменты. Если вы хотите увидеть его в действии, вот рабочая демонстрация. Он будет переключаться между тремя моими любимыми книгами по трем моим любимым языкам программирования. URL-адрес для каждой книги будет каждый раз уникальным, поэтому вы можете увидеть предварительный просмотр, хотя вы, вероятно, захотите ограничить вещи на вкладке «Сеть DevTools», чтобы действительно увидеть, что происходит.

Посмотреть весь код
class BookCover extends HTMLElement { static observedAttributes = ['url']; attributeChangedCallback(name, oldValue, newValue) { if (name === 'url') { this.createMainImage(newValue); } } set preview(val) { this.previewEl = this.createPreview(val); this.render(); } createPreview(val) { if (typeof val === 'string') { return base64Preview(val); } else { return blurHashPreview(val); } } createMainImage(url) { this.loaded = false; const img = document.createElement('img'); img.alt = 'Book cover'; img.addEventListener('load', () =&gt; { if (img === this.imageEl) { this.loaded = true; this.render(); } }); img.src = url; this.imageEl = img; } connectedCallback() { this.render(); } render() { const elementMaybe = this.loaded ? this.imageEl : this.previewEl; syncSingleChild(this, elementMaybe); }
}

Во-первых, мы регистрируем интересующий нас атрибут и реагируем на его изменение:

static observedAttributes = ['url']; attributeChangedCallback(name, oldValue, newValue) { if (name === 'url') { this.createMainImage(newValue); }
}

Это приводит к созданию нашего компонента изображения, который будет отображаться только при загрузке:

createMainImage(url) { this.loaded = false; const img = document.createElement('img'); img.alt = 'Book cover'; img.addEventListener('load', () => { if (img === this.imageEl) { this.loaded = true; this.render(); } }); img.src = url; this.imageEl = img;
}

Затем у нас есть наше свойство предварительного просмотра, которое может быть либо нашей строкой предварительного просмотра base64, либо нашим blurhash пакет:

set preview(val) { this.previewEl = this.createPreview(val); this.render();
} createPreview(val) { if (typeof val === 'string') { return base64Preview(val); } else { return blurHashPreview(val); }
}

Это относится к любой вспомогательной функции, которая нам нужна:

function base64Preview(val) { const img = document.createElement('img'); img.src = val; return img;
} function blurHashPreview(preview) { const canvasEl = document.createElement('canvas'); const { w: width, h: height } = preview; canvasEl.width = width; canvasEl.height = height; const pixels = decode(preview.blurhash, width, height); const ctx = canvasEl.getContext('2d'); const imageData = ctx.createImageData(width, height); imageData.data.set(pixels); ctx.putImageData(imageData, 0, 0); return canvasEl;
}

И, наконец, наш render Метод:

connectedCallback() { this.render();
} render() { const elementMaybe = this.loaded ? this.imageEl : this.previewEl; syncSingleChild(this, elementMaybe);
}

И несколько вспомогательных методов, чтобы связать все воедино:

export function syncSingleChild(container, child) { const currentChild = container.firstElementChild; if (currentChild !== child) { clearContainer(container); if (child) { container.appendChild(child); } }
} export function clearContainer(el) { let child; while ((child = el.firstElementChild)) { el.removeChild(child); }
}

Это немного более шаблонно, чем нам нужно, если мы создадим это во фреймворке, но преимущество в том, что мы можем повторно использовать это в любом фреймворке, который нам нужен — хотя React пока понадобится обертка, как мы обсуждали .

Шансы и заканчивается

Я уже упоминал обертку Lit React. Но если вы обнаружите, что используете Stencil, он на самом деле поддерживает отдельный выходной конвейер только для React. И хорошие ребята из Microsoft тоже создал что-то похожее на обертку Лита, прикрепленный к библиотеке веб-компонентов Fast.

Как я уже упоминал, все фреймворки, не названные React, будут обрабатывать настройку свойств веб-компонента за вас. Просто обратите внимание, что некоторые из них имеют некоторые особенности синтаксиса. Например, с Solid.js, <your-wc value={12}> всегда предполагает, что value это свойство, которое вы можете переопределить с помощью attr префикс, например <your-wc attr:value={12}>.

Подведение итогов

Веб-компоненты — это интересная, часто недостаточно используемая часть ландшафта веб-разработки. Они могут помочь уменьшить вашу зависимость от какой-либо отдельной среды JavaScript, управляя вашим пользовательским интерфейсом или «конечными» компонентами. Хотя создание их в виде веб-компонентов — в отличие от компонентов Svelte или React — будет не таким эргономичным, преимуществом является то, что их можно будет широко использовать повторно.


Создание взаимодействующих веб-компонентов, которые работают даже с React первоначально опубликовано CSS-хитрости, Вам следует получить информационный бюллетень.

Отметка времени:

Больше от CSS хитрости