Material как WebComponents

в 9:49, , рубрики: angular, javascript, web components

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

Не очень давно я пытался найти приличный грид для веб-компонент, на тот момент такового, полноценного, но при том не обязывающего использовать какой-либо фреймворк, особенно если он представляет собой что-то типа Polymer не оказалось. В недалеком прошлом у меня был до того достаточно успешный опыт с material/cdk. Тогда мне относительно легко удавалось серьезно кастомизировать фильтры и пейджер для таблицы, локализовать подсказки и все это без переписывания библиотечного кода или мрачных хуков, использовав механизмы переопределения. На момент рассмотрения оказалось, что биндингов конкретно для компонента таблицы еще не успели сделать, но вот пару недель назад я заметил, что в репозитории на эту тему что-то появилось и решил в рамках эксперимента попробовать подключить их как веб-компоненты.

Для того, что-бы начать пользоваться компонентами material достаточно подключить бандл с кодом и еще один ресурс со всеми стилями, прямо как у любителей VueJS. Например, создать поле ввода управляемое angular/material можно следующим образом:

<link rel="stylesheet" href="https://unpkg.com/material-components-web@latest/dist/material-components-web.min.css">
<script src="https://unpkg.com/material-components-web@latest/dist/material-components-web.min.js"></script>

<div class="mdc-text-field">
   <input type="text" id="my-text-field" class="mdc-text-field__input">
   <label class="mdc-floating-label" for="my-text-field">Label</label>
   <div class="mdc-line-ripple"></div>
</div>

<script>
   mdc.textField.MDCTextField.attachTo(document.querySelector('.mdc-text-field'));
</script>

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

<div class="mdc-text-field">
   <input type="text" id="my-text-field" class="mdc-text-field__input">
   <label class="mdc-floating-label" for="my-text-field">Label</label>
   <div class="mdc-line-ripple"></div>
</div>
<div class="mdc-data-table">
   <table class="mdc-data-table__table" aria-label="Dessert calories">
       <thead>
       <tr class="mdc-data-table__header-row">
           <th class="mdc-data-table__header-cell" role="columnheader" scope="col">Dessert</th>
           <th class="mdc-data-table__header-cell" role="columnheader" scope="col">Carbs (g)</th>
           <th class="mdc-data-table__header-cell" role="columnheader" scope="col">Protein (g)</th>
           <th class="mdc-data-table__header-cell" role="columnheader" scope="col">Comments</th>
       </tr>
       </thead>
       <tbody class="mdc-data-table__content">
       <tr class="mdc-data-table__row">
           <td class="mdc-data-table__cell">Frozen yogurt</td>
           <td class="mdc-data-table__cell mdc-data-table__cell--numeric">24</td>
           <td class="mdc-data-table__cell mdc-data-table__cell--numeric">4.0</td>
           <td class="mdc-data-table__cell">Super tasty</td>
       </tr>
       <tr class="mdc-data-table__row">
           <td class="mdc-data-table__cell">Ice cream sandwich</td>
           <td class="mdc-data-table__cell mdc-data-table__cell--numeric">37</td>
           <td class="mdc-data-table__cell mdc-data-table__cell--numeric">4.3</td>
           <td class="mdc-data-table__cell">I like ice cream more</td>
       </tr>
       <tr class="mdc-data-table__row">
           <td class="mdc-data-table__cell">Eclair</td>
           <td class="mdc-data-table__cell mdc-data-table__cell--numeric">24</td>
           <td class="mdc-data-table__cell mdc-data-table__cell--numeric">6.0</td>
           <td class="mdc-data-table__cell">New filing flavor</td>
       </tr>
       </tbody>
   </table>
</div>

<script src="https://unpkg.com/material-components-web@latest/dist/material-components-web.min.js"></script>

<script type="module">
   let filterField = mdc.textField.MDCTextField.attachTo(document.querySelector('.mdc-text-field'));


   let dataTable = new mdc.dataTable.MDCDataTable(document.querySelector('.mdc-data-table'));
</script>

Подключая из уже собранных бандлов с unpkg мы к сожалению не используем нативных модульных возможностей браузера WHATWG, т.к. эти бандлы биндят компоненты в глобальное пространство имен и его объект mdc, а не экспортируют по модульному стандарту ES6. Но такой вариант возможно будет привычнее консервативно настроенным специалистам и может заработать без транспиляторов в браузерах наследия.

Material как WebComponents - 1

Список реализованных компонентов можно смотреть вот в этом репозитории.

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

Однако, мне удалось нагуглить один пример позволяющий получить доступ к пока скрытому от нас апи, через наследование. Кроме того, вам следует знать также об том, что с помощью наработок проекта Angular Elements, вы можете вести разработку компонентов в инфраструктуре фреймворка и самостоятельно экспонировать их в браузерное API и те же CustomElements.

Я взял пример целиком, внеся некоторые исправления, “чтобы заработало” на исходной верстке и было понятно куда ковырять после прочтения статьи. Кода много и теперь он свернут.

Код таблицы

<link rel="stylesheet" href="https://unpkg.com/material-components-web@latest/dist/material-components-web.min.css">

<div class="mdc-text-field">
   <input type="text" id="my-text-field" class="mdc-text-field__input">
   <label class="mdc-floating-label" for="my-text-field">Label</label>
   <div class="mdc-line-ripple"></div>
</div>
<div class="mdc-data-table">
   <table class="mdc-data-table__table" aria-label="Dessert calories">
       <thead>
       <tr class="mdc-data-table__header-row">
           <th class="mdc-data-table__header-cell" role="columnheader" scope="col">Dessert</th>
           <th class="mdc-data-table__header-cell" role="columnheader" scope="col">Carbs (g)</th>
           <th class="mdc-data-table__header-cell" role="columnheader" scope="col">Protein (g)</th>
           <th class="mdc-data-table__header-cell" role="columnheader" scope="col">Comments</th>
       </tr>
       </thead>
       <tbody class="mdc-data-table__content">
       <tr class="mdc-data-table__row">
           <td class="mdc-data-table__cell">Frozen yogurt</td>
           <td class="mdc-data-table__cell mdc-data-table__cell--numeric">24</td>
           <td class="mdc-data-table__cell mdc-data-table__cell--numeric">4.0</td>
           <td class="mdc-data-table__cell">Super tasty</td>
       </tr>
       <tr class="mdc-data-table__row">
           <td class="mdc-data-table__cell">Ice cream sandwich</td>
           <td class="mdc-data-table__cell mdc-data-table__cell--numeric">37</td>
           <td class="mdc-data-table__cell mdc-data-table__cell--numeric">4.3</td>
           <td class="mdc-data-table__cell">I like ice cream more</td>
       </tr>
       <tr class="mdc-data-table__row">
           <td class="mdc-data-table__cell">Eclair</td>
           <td class="mdc-data-table__cell mdc-data-table__cell--numeric">24</td>
           <td class="mdc-data-table__cell mdc-data-table__cell--numeric">6.0</td>
           <td class="mdc-data-table__cell">New filing flavor</td>
       </tr>
       </tbody>
   </table>
</div>

<script src="https://unpkg.com/material-components-web@latest/dist/material-components-web.js"></script>

<script type="module">
   let filterField = mdc.textField.MDCTextField.attachTo(document.querySelector('.mdc-text-field'));

   const DATATABLE_COLUMNS_SELECTOR = `.mdc-data-table thead`,
       DATATABLE_DATA_SELECTOR = `tbody.mdc-data-table__content`,
       DATATABLE_SORTABLE_SELECTOR = `.mdc-data-table--sortable`,
       DATATABLE_COLUMNS_NUMERIC = `mdc-data-table--numeric`,
       DATATABLE_COLUMNS_SORTABLE = `mdc-data-table--sortable`,
       DATATABLE_COLUMNS_SORT_ASC = `mdc-data-table--sort-asc`,
       DATATABLE_COLUMNS_SORT_DESC = `mdc-data-table--sort-desc`;

   class MyDataTable extends mdc.dataTable.MDCDataTable {

       get data() {
           return this.foundation_.data;
       }

       set data(data) {
           if (Array.isArray(data)) {
               this.foundation_.setData(data);
           } else {
               throw new Error(`Expected an array`);
           }
       }

       layout() {
           if (this.foundation_.layout) {
               this.foundation_.layout();
           }
       }

       getDefaultFoundation() {

           const getHeaderRow = () => {
               let thead = this.root_.querySelector(DATATABLE_COLUMNS_SELECTOR),
                   row = thead.querySelector(`tr`);
               if (!row) {
                   row = document.createElement(`tr`);
                   row.setAttribute(`role`, `rowheader`);
                   thead.appendChild(row);
               }
               return row;
           },
           getHeaderColumns = () => {
               return getHeaderRow().querySelectorAll(`th`);
           },
           emptyHeaderColumns = () => {
               getHeaderRow().remove();
           },
           getData = () => {
               return this.root_.querySelector(DATATABLE_DATA_SELECTOR);
           },
           getDataRows = () => {
               return getData().querySelectorAll(`tr`);
           },
           emptyData = () => {
               Array.prototype.map.call(getDataRows(), row => {
                   row.remove();
               });
           };

           return new MyDataTableFoundation({
               registerSortClickHandler: (handler) => this.root_.addEventListener(`click`, handler),
               deregisterSortClickHandler: (handler) => this.root_.removeEventListener(`click`, handler),
               // Reads the columns list
               readColumns: () => {
                   var cols = getHeaderColumns();
                   return Array.prototype.map.call(cols, col => {
                       return {
                           text: col.textContent,
                           description: col.getAttribute(`aria-label`),
                           numeric: col.classList.contains(DATATABLE_COLUMNS_NUMERIC),
                           sortable: col.classList.contains(DATATABLE_COLUMNS_SORTABLE),
                           sort: col.classList.contains(DATATABLE_COLUMNS_SORT_ASC) ? 1 : col.classList.contains(DATATABLE_COLUMNS_SORT_DESC) ? -1 : 0
                       };
                   });
               },
               // Edit the columns
               setColumns: (cols) => {
                   emptyHeaderColumns();
                   let row = getHeaderRow();
                   cols.forEach(col => {
                       let column = document.createElement(`th`);
                       column.setAttribute(`role`, `columnheader`);
                       // Add text
                       column.textContent = col.text;
                       column.setAttribute(`aria-label`, col.description);
                       // Numeric
                       if (col.numeric) {
                           column.classList.add(DATATABLE_COLUMNS_NUMERIC);
                       }
                       // Sort
                       if (col.sortable) {
                           let ariaSort = `none`;
                           column.classList.add(DATATABLE_COLUMNS_SORTABLE);
                           if (col.sort === `asc` || col.sort === 1) {
                               ariaSort = `ascending`;
                               column.classList.add(DATATABLE_COLUMNS_SORT_ASC);
                           } else if (col.sort === `desc` || col.sort === -1) {
                               ariaSort = `descending`;
                               column.classList.add(DATATABLE_COLUMNS_SORT_DESC);
                           }
                           column.setAttribute(`aria-sort`, ariaSort);
                       }
                       // Add to cols
                       row.appendChild(column);
                   });
               },
               // Read data
               readData: () => {
                   var rows = getDataRows();
                   return Array.prototype.map.call(rows, row => {
                       let cells = row.querySelectorAll(`td`);
                       return Array.prototype.map.call(cells, cell => cell.textContent);
                   });
               },
               // Edit the data
               setData: (data) => {
                   emptyData();
                   let element = getData();
                   // Sorting data
                   let column = this.columns.find(el => el.sort);
                   if (column) {
                       let index = this.columns.indexOf(column);
                       if (column.sortable) {
                           let f = (params => {
                               if (params.sort === `desc` || params.sort === -1) {
                                   return params.numeric ? (a, b) => b[index] - a[index] : (a, b) => b[index].localeCompare(a[index]);
                               } else {
                                   return params.numeric ? (a, b) => a[index] - b[index] : (a, b) => a[index].localeCompare(b[index]);
                               }
                           })(column);
                           data.sort(f);
                       }
                   }
                   // For each data
                   data.forEach(d => {
                       // Create a new row
                       let row = document.createElement(`tr`);
                       row.setAttribute(`role`, `row`);
                       // For each values
                       d.forEach((val, i) => {
                           // Create a new cell
                           let cell = document.createElement(`td`);
                           cell.setAttribute(`role`, `gridcell`);
                           // Add numeric if needed
                           if (this.columns[i].numeric) {
                               cell.classList.add(DATATABLE_COLUMNS_NUMERIC);
                           }
                           // Add content
                           if (val instanceof Element) {
                               cell.appendChild(val);
                           } else {
                               cell.textContent = val;
                           }
                           row.appendChild(cell);
                       });
                       // Add to cols
                       element.appendChild(row);
                   });
               },
               // Redraw data table after edit
               redraw: () => {
                   this.foundation_.adapter_.setColumns(this.columns);
                   this.foundation_.adapter_.setData(this.data);
               }
           });
       }
   }

   mdc.autoInit.register(`MDCDataTable`, MyDataTable);

   class MyDataTableFoundation extends mdc.base.MDCFoundation {

       static get defaultAdapter() {
           return {
               registerSortClickHandler: ( /* handler: EventListener */ ) => {},
               deregisterSortClickHandler: ( /* handler: EventListener */ ) => {},
               readColumns: () => {},
               setColumns: () => {},
               readData: () => {},
               setData: () => {},
               redraw: () => {}
           };
       }

       constructor(adapter) {
           super(Object.assign(MyDataTableFoundation.defaultAdapter, adapter));
           // Attributes
           this.columns = [];
           this.data = [];
           // Methods
           // On sort
           this.sortClickHandler_ = (e) => {
               let target = e.target.closest(DATATABLE_SORTABLE_SELECTOR);
               if (target) {
                   let index = Array.prototype.indexOf.call(target.parentElement.children, target);
                   this.columns.forEach((col, i) => {
                       if (i !== index) {
                           col.sort = 0;
                       } else {
                           if (col.sort === `asc` || col.sort === 1) {
                               col.sort = `desc`;
                           } else {
                               col.sort = `asc`;
                           }
                       }
                   });
                   this.adapter_.redraw();
               }
           };
       }

       init() {
           // Read columns
           this.columns = this.adapter_.readColumns();
           // Read data
           this.data = this.adapter_.readData();
           // Click
           this.adapter_.registerSortClickHandler(this.sortClickHandler_);
       }

       destroy() {
           // Click
           this.adapter_.deregisterSortClickHandler(this.sortClickHandler_);
       }

       setColumns(cols) {
           this.adapter_.setColumns(cols);
       }

       setData(data) {
           this.adapter_.setData(data);
       }

   }

   let dataTable = new MyDataTable(document.querySelector('.mdc-data-table'));

</script>

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

let filterField = mdc.textField.MDCTextField.attachTo(document.querySelector('.mdc-text-field'));
filterField.input_.oninput = (event) => {
   dataTable.origData = dataTable.origData || dataTable.data.slice();
   if (event.target.value == '') {
       dataTable.data = dataTable.origData.slice();
   } else {
       let data = dataTable.origData.filter((row) => {
           let rowIsOk = false;
           for (let item of row) {
               if (item.indexOf(event.target.value) > 0) {
                   rowIsOk = true;
               }
           }
           return rowIsOk;
       }) || [];
       dataTable.data = data;
       dataTable.getDefaultFoundation().redraw();
   }
};

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

Material как WebComponents - 2
после ввода данных в поле, содержимое будет отфильтровано

Material как WebComponents - 3
Этот пример, особенно после вынесения всей логики на джаваскрипте в отдельные файлы-классы, как мы это делали в первой статье цикла, может стать отправной точкой для вас, чтобы переиспользовать компоненты angular/material cdk или другого тулкита для собственной разработки переопределяя поведение, чтобы не “колхозить” все с нуля или интегрируя новый код в существующую инфраструктуру перестав наращивать монолит, т.к. веб-компоненты дают лучший способ модульной организации разработок.

Автор: syncro

Источник


* - обязательные к заполнению поля