В этой статье, я хочу показать базовые приемы работы с HTML-таблицами при использовании библиотеки Symbiote.js. Но, сперва, напомню, что это, вообще, такое.

Основы

Две главные вещи, которые нужно знать о Symbiote.js:

1) Symbiote.js - это библиотека для создания интерфейсных компонентов любой сложности, основанная на стандарте Custom Elements. Каждый созданный вами компонент - это полноправный DOM-элемент, со своим собственным API, который будет доступен через самые обычные селекторы и может быть использован совместно с, практически, любым набором веб-технологий. Он полный агностик, и будет дружить как с вашими фронтенд-либами, так и с любым подходом по созданию HTML-документов на сервере.

2) Symbiote.js использует для описания своих шаблонов формат, который может быть преобразован в элементы DOM стандартным парсером браузера без дополнительной обработки. То есть, это, буквально, фрагмент HTML, в виде строки. Таким образом, Symbiote.js может брать под контроль предварительно созданные участки документа или манипулировать шаблонами с очень высокой степенью гибкости. Шаблон может быть сформирован миллионом способов и вам не нужно думать о том, что это какой-то специальный объект в JS-рантайме, который зависит от контекста самого компонента или имеет какие-то иные ограничения. Наверное, это основное отличие от таких решений, как Lit от разработчиков из Google.

Рисуем таблицу

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

Для решения этой задачи, сперва, мы установим нашу базовую зависимость:

npm i @symbiotejs/symbiote

Затем, создадим кастомный тег:

import Symbiote from '@symbiotejs/symbiote';

class MyTable extends Symbiote {}

MyTable.reg('my-table');

Теперь, если браузер встретит в разметке тег my-table, он сам вызовет конструктор класса MyTable и, таким образом, инициализирует наш компонент. Вам не нужно специально следить за тем, когда ваш тег появился или исчез из DOM, весь контроль жизненного цикла инкапсулирован в наш класс и находится полностью под нашим контролем.

Добавляем таблицу:

import Symbiote from '@symbiotejs/symbiote';

class MyTable extends Symbiote {
  init$ = {
    tableData: [
      {
        rowNumber: 1,
        date: Date.now(),
      },
      {
        rowNumber: 2,
        date: Date.now(),
      },
      {
        rowNumber: 3,
        date: Date.now(),
      },
    ],
  }
}

MyTable.template = /*html*/ `
  <table itemize="tableData" item-tag="table-row"></table>
`;

MyTable.reg('my-table');

Здесь мы инициализировали тестовые данные и создали шаблон с таблицей-контейнером, в которой они должны появиться. Шаблон - это самый обычный шаблонный литерал JS, который можно импортировать из другого файла, или даже получить по запросу с сервера.

Давайте взглянем на него подробнее:

<table itemize="tableData" item-tag="table-row"></table>

Как видите, привязка данных таблицы осуществлена с помощью атрибута itemize, и, дополнительно, указан тег, который будет использован для создания каждого элемента списка - table-row.

Itemize API в Symbiote.js работает так:

  • Для каждого элемента списка создается полноценный компонент со своим локальным стейтом

  • Если в реестре браузера уже зарегистрирован кастомный элемент с таким именем - он будет использован для вывода элемента списка

  • Если тег элемента не указан - он будет создан автоматически с уникальным, для данного списка, именем (например: sym-1) и режимом отображения display: contents

  • Если имя тега указано, но не зарегистрировано - тег будет создан и зарегистрирован автоматически с указанным именем

  • Поскольку элемент списка - это полноценный компонент, его шаблон поддерживает вложенные списки. Таким образом, вы можете отобразить сложную древовидную структуру

Стоит остановиться на важной особенности рендеринга стандартных таблиц браузерами: если в теле таблицы браузер встречает теги, отличные от стандартных "табличных" (таких как tr, td), он автоматически создаст дополнительные теги tbody, которые нам будут совсем не нужны в итоговой разметке. Поэтому, если в качестве контейнера списка мы используем стандартный тег table, для отображения элементов нам лучше будет создать отдельный тег вручную. Также, нам это может понадобиться, если мы хотим реализовать там какую-то сложную логику.

Создаём табличную строку:

import Symbiote from '@symbiotejs/symbiote';

class TableRow extends Symbiote {
  
  renderCallback() {
    // Some custom logic:
    this.onclick = () => {
      this.classList.toggle('selected');
    };
  }

}

TableRow.rootStyles = /*css*/ `
  table-row {
    display: table-row;
    &.selected {
      background-color: rgba(255, 0, 200, .3);
    }
  }
`;

TableRow.template = /*html*/ `
  <td>Row number: {{rowNumber}}</td>
  <td>Date: {{date}}</td>
`;

TableRow.reg('table-row');

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

Также, мы добавили стиль нашего элемента списка, чтобы он расценивался браузером именно как табличная строка. Это можно делать в обычном внешнем CSS, в собственных стилях элемента списка (как в нашем примере), или в стилях компонента-контейнера. Все зависит только от ваших целей, привычек и предпочтений. В нашем случае, был использован интерфейс rootStyles, который позволяет задавать правила стилизации в общем теневом контексте (если вы используете Shadow DOM), либо, в общем контексте всего документа. Таким образом, если вам понадобиться включить теневой режим (вдруг приспичит?) где-то в родительском компоненте - все будет продолжать работать, как было задумано. Кстати, зацените современный CSS nesting: да, теперь так можно.

Наш базовый пример готов:

import Symbiote from '@symbiotejs/symbiote';

// Table row component:
class TableRow extends Symbiote {
  // Some custom logic:
  renderCallback() {
    this.onclick = () => {
      this.classList.toggle('selected');
    };
  }

}

TableRow.rootStyles = /*css*/ `
  table-row {
    display: table-row;
    &.selected {
      background-color: rgba(255, 0, 200, .3);
    }
  }
`;

TableRow.template = /*html*/ `
  <td>Row number: {{rowNumber}}</td>
  <td>Date: {{date}}</td>
`;

TableRow.reg('table-row');

// Table component:
class MyTable extends Symbiote {
  init$ = {
    tableData: [
      {
        rowNumber: 1,
        date: Date.now(),
      },
      {
        rowNumber: 2,
        date: Date.now(),
      },
      {
        rowNumber: 3,
        date: Date.now(),
      },
    ],
  }
}

MyTable.template = /*html*/ `
  <table itemize="tableData" item-tag="table-row"></table>
`;

MyTable.reg('my-table');

Теперь, при использовании тега my-table где-либо в документе, мы увидим следующий результат:

<my-table>
  <table>
    <table-row>
      <td>Row number: 1</td>
      <td>Date: 1714405915233</td>
    </table-row>
    <table-row>
      <td>Row number: 2</td>
      <td>Date: 1714405915233</td>
    </table-row>
    <table-row>
      <td>Row number: 3</td>
      <td>Date: 1714405915233</td>
    </table-row>
  </table>
</my-table>

Альтернативный вариант

Теперь, сделаем почти тоже самое, но гораздо короче:

import Symbiote from '@symbiotejs/symbiote';

class MyTable extends Symbiote {
  init$ = {
    tableData: [],
    select: (e) => {
      e.target?.closest('table-row')?.classList.toggle('selected');
    },
  }
}

MyTable.template = /*html*/ `
  <table-css 
    bind="onclick: select"
    itemize="tableData" 
    item-tag="table-row">
    <td-css>Row number: {{rowNumber}}</td-css>
    <td-css>Date: {{date}}</td-css>
  </table-css>
`;

MyTable.reg('my-table');

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

Для придания большей табличности нашей таблице, используем СSS:

table-css {
  display: table;
  border-spacing: 2px;
  border-collapse: separate;
  table-row {
    display: table-row;
    &.selected {
      background-color: rgba(255, 0, 200, .3);
    }
    td-css {
      display: table-cell;
      border: 1px solid currentColor;
      padding: 4px;
    }
  }
}

Передаем данные:

document.querySelector('my-table').$.tableData = [
  {
    rowNumber: 1,
    date: Date.now(),
  },
  {
    rowNumber: 2,
    date: Date.now(),
  },
  {
    rowNumber: 3,
    date: Date.now(),
  },
];

Получаем:

<my-table>
  <table-css>
    <table-row>
      <td-css>Row number: 1</td-css>
      <td-css>Date: 1714405915233</td-css>
    </table-row>
    <table-row>
      <td-css>Row number: 2</td-css>
      <td-css>Date: 1714405915233</td-css>
    </table-row>
    <table-row>
      <td-css>Row number: 3</td-css>
      <td-css>Date: 1714405915233</td-css>
    </table-row>
  </table-css>
</my-table>

Вуаля. Обновлять данные можно динамически (как и показано в примере), при этом, изменения DOM будут вноситься только туда, где изменились сами данные, включая все вложенные списки, если они есть. Для передачи данных мы использовали интерфейс $, который дает доступ к управлению состоянием, и может использоваться одинаково как во внутренней логике компонентов, так и в качестве внешнего API.

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

<div itemize="userList" item-tag="user-card">
  <div>ID: {{_KEY_}}</div>
  <div>{{firstName}}</div>
  <div>{{secondName}}</div>
</div>

Последний штрих

Помните мы установили Symbiote.js через npm? Это было нужно, прежде всего, для нормальной работы инструментов окружения разработки, таких как TypeScript. Но для использования общих зависимостей в среде исполнения непосредственно, я рекомендую использовать Import Maps и не включать их (общие зависимости) в вашу сборку. Так вы можете исключить дублирование кода (и, что менее очевидно, контекстов модулей) в разных сегментах вашей сложной UI-системы.

Давайте добавим запись в карту импортов вашего HTML-документа:

{
  "imports": {
    "@symbiotejs/symbiote": "https://esm.run/@symbiotejs/symbiote"
  }
}

Готово. С живым примером вы можете поиграть тут: https://symbiotejs.org/2x/playground/table-css/

Спасибо за внимание.

Комментарии (6)


  1. caballero
    30.04.2024 21:19

    Если речь о таблице то с помощью того же mustache делается гораздо проще.

    Вот компоненты типа select2 переписать был бы здорово


    1. i360u Автор
      30.04.2024 21:19
      +2

      Динамический рендеринг с помощью mustache? Серьезно?


    1. DarthVictor
      30.04.2024 21:19
      +1

      У таблиц ведь могут быть и фиксированные заголовки или столбцы и много чего ещё, что без JS нормально не сделаешь.


  1. alexnozer
    30.04.2024 21:19
    +2

    В таком варианте получается невалидная разметка таблицы. В table разрешено использовать только caption, colgroup, thead, tbody, tfoot, tr, template и script. Остальные элементы запрещены, в том числе и кастомные элементы. Добавление display: contents хоть и убирает элемент из расчёта раскладки, но технически он остаётся в DOM и AOM.

    В итоге валидатор ругается и останавливает дальнейший анализ разметки, теряется семантика таблицы для ассистивных технологий, перестают работать API-методы таблиц (добавление строк, считывание данных и т.д.). Поэтому в таком варианте использования стоит делать всю таблицу на кастомных элементах и добавлять семантику через ARIA атрибуты или ARIA Reflection API.


    1. i360u Автор
      30.04.2024 21:19

      Я, наверное, зря не сделал на этом дополнительный акцент, но в качестве элементов списка в itemize могут выступать Custom Elements созданные ЛЮБЫМ способом, что круто, само по себе. Например так:

      class TableRow extends HTMLTableRowElement {
      
        constructor() {
          super();
          this.innerHTML = /*html*/ `<td></td><td></td>`;
        }
      
        set name(val) {
          this.cells.item(0).textContent = val;
        }
      
        set date(val) {
          this.cells.item(1).textContent = val;
        }
      }
      
      window.customElements.define('table-row', TableRow, {
        extends: 'tr',
      });

      В этом случае, все элементы таблицы будут абсолютно стандартными тегами со своими стандартными методами API. Но, придется использовать полифил для Safari:

      import {} from 'https://cdn.jsdelivr.net/npm/@ungap/custom-elements/+esm';


      1. alexnozer
        30.04.2024 21:19

        Судя по примеру, речь идёт о Сustomized built-in elements (наследование от других существующих элементов, а не HTMLElement). Насколько мне известно, эта фича deprecated как раз из-за того, что у инженеров Safari нашлись причины не реализовывать это. Вслед за этим на собрании рабочей группы веб-компонентов API customized built-in elements было отклонено и со временем будет удалено из других браузеров. Поэтому я бы не стал полагаться на это, даже с полифилом.

        Для валидности мне видится вариант сделать таблицу целиком на кастомных элементах + ARIA + display: table-*.