В этой статье, я хочу показать базовые приемы работы с 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/

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