Реалізація кастомних HTML-елементів для вбудовування віджетів

Наша компанія займається розробкою, підтримкою та обслуговуванням сайтів будь-якої складності. Від простих односторінкових сайтів до масштабних кластерних систем, побудованих на мікро сервісах. Досвід розробників підтверджено сертифікатами від вендорів.

Розробка та обслуговування будь-яких видів сайтів:

Інформаційні сайти або веб-програми
Сайти візитки, landing page, корпоративні сайти, онлайн каталоги, квіз, промо-сайти, блоги, ресурси новин, інформаційні портали, форуми, агрегатори
Сайти або веб-програми електронної комерції
Інтернет-магазини, B2B-портали, маркетплейси, онлайн-обмінники, кешбек-сайти, біржі, дропшиппінг-платформи, парсери товарів
Веб-програми для управління бізнес-процесами
CRM-системи, ERP-системи, корпоративні портали, системи управління виробництвом, парсери інформації
Сайти або веб-програми електронних послуг
Дошки оголошень, онлайн-школи, онлайн-кінотеатри, конструктори сайтів, портали надання електронних послуг, відеохостинги, тематичні портали

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Реалізація кастомних HTML-елементів для вбудовування віджетів
Середня
~3-5 робочих днів
Часті питання

Наші компетенції:

Етапи розробки

Останні роботи

  • image_website-b2b-advance_0.png
    Розробка сайту компанії B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    874
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Розробка веб-сайту для компанії ФІКСПЕР
    851

Реалізація кастомних HTML-елементів для вбудовування віджетів

Сторонній віджет на чужому веб-сайті — це завжди чужа DOM, чужі стилі, потенційно конфліктуючі версії бібліотек. Кастомні HTML-елементи (Custom Elements v1) забезпечують чистий публічний API: один тег, атрибути, події. Клієнт вставляє три рядки коду й отримує робочий віджет.

Архітектура вбудовуваного віджету

Завдання відрізняється від внутрішніх компонентів. Немає контролю над хост-сторінкою: може бути jQuery 1.x, Bootstrap 3, випадковий CSS-reset або * { all: unset }. Віджет повинен працювати в будь-якому середовищі.

Shadow DOM тут не варіант, а необхідність. Він гарантує ізоляцію стилів в обидві сторони: наші стилі не витікають до хосту, хостові стилі не ламають нас.

<!-- Що отримує клієнт -->
<script src="https://cdn.example.com/widget.js" async></script>
<review-widget
  data-product-id="SKU-12345"
  data-theme="light"
  data-locale="ua"
></review-widget>

Реалізація Custom Element

// src/ReviewWidget.ts
const TEMPLATE = document.createElement('template');
TEMPLATE.innerHTML = `
  <style>
    :host {
      display: block;
      contain: content;
      font-family: var(--rw-font, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif);
      font-size: var(--rw-font-size, 14px);
      color: var(--rw-text-color, #1a1a2e);
    }
    :host([hidden]) { display: none; }
    .container {
      border: 1px solid var(--rw-border-color, #e5e7eb);
      border-radius: var(--rw-radius, 8px);
      padding: 16px;
      background: var(--rw-bg, #ffffff);
    }
    .rating {
      display: flex;
      align-items: center;
      gap: 4px;
      margin-bottom: 12px;
    }
    .star { color: #f59e0b; font-size: 18px; }
    .star.empty { color: #d1d5db; }
    .reviews-list { list-style: none; margin: 0; padding: 0; }
    .review-item {
      padding: 10px 0;
      border-top: 1px solid #f3f4f6;
    }
    .review-author { font-weight: 600; font-size: 13px; }
    .review-text { margin-top: 4px; line-height: 1.5; }
    .load-more {
      margin-top: 12px;
      width: 100%;
      padding: 8px;
      background: var(--rw-accent, #3b82f6);
      color: #fff;
      border: none;
      border-radius: 6px;
      cursor: pointer;
      font-size: 13px;
    }
    .load-more:hover { opacity: .9; }
    .skeleton {
      background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
      background-size: 200% 100%;
      animation: shimmer 1.5s infinite;
      border-radius: 4px;
      height: 14px;
      margin: 6px 0;
    }
    @keyframes shimmer {
      0% { background-position: 200% 0; }
      100% { background-position: -200% 0; }
    }
  </style>
  <div class="container">
    <div class="rating" aria-label="Рейтинг товару"></div>
    <ul class="reviews-list" role="list"></ul>
    <button class="load-more" style="display:none">Показати більше</button>
  </div>
`;

interface Review {
  id: string;
  author: string;
  rating: number;
  text: string;
  date: string;
}

export class ReviewWidget extends HTMLElement {
  static get observedAttributes() {
    return ['data-product-id', 'data-theme', 'data-locale'];
  }

  private shadow: ShadowRoot;
  private page = 1;
  private allLoaded = false;
  private apiBase = 'https://api.example.com/reviews';

  constructor() {
    super();
    this.shadow = this.attachShadow({ mode: 'open' });
    this.shadow.appendChild(TEMPLATE.content.cloneNode(true));
  }

  connectedCallback() {
    this.applyTheme();
    this.fetchReviews(true);

    this.shadow.querySelector('.load-more')
      ?.addEventListener('click', () => this.fetchReviews(false));
  }

  attributeChangedCallback(name: string, old: string, next: string) {
    if (old === next) return;
    if (name === 'data-product-id') {
      this.page = 1;
      this.allLoaded = false;
      this.fetchReviews(true);
    }
    if (name === 'data-theme') {
      this.applyTheme();
    }
  }

  private applyTheme() {
    const theme = this.dataset.theme ?? 'light';
    if (theme === 'dark') {
      const container = this.shadow.querySelector<HTMLElement>('.container');
      if (container) {
        container.style.setProperty('--rw-bg', '#1f2937');
        container.style.setProperty('--rw-text-color', '#f9fafb');
        container.style.setProperty('--rw-border-color', '#374151');
      }
    }
  }

  private showSkeleton() {
    const list = this.shadow.querySelector('.reviews-list')!;
    list.innerHTML = Array(3).fill(
      '<li><div class="skeleton"></div><div class="skeleton" style="width:70%"></div></li>'
    ).join('');
  }

  private async fetchReviews(reset: boolean) {
    const productId = this.dataset.productId;
    if (!productId) return;

    if (reset) {
      this.page = 1;
      this.showSkeleton();
    }

    try {
      const url = new URL(`${this.apiBase}/${productId}`);
      url.searchParams.set('page', String(this.page));
      url.searchParams.set('per_page', '5');
      url.searchParams.set('locale', this.dataset.locale ?? 'ua');

      const res = await fetch(url.toString(), {
        headers: { 'X-Widget-Version': '2.1.0' },
      });

      if (!res.ok) throw new Error(`HTTP ${res.status}`);

      const data: { reviews: Review[]; total: number; avg_rating: number } =
        await res.json();

      this.renderRating(data.avg_rating, data.total);
      this.renderReviews(data.reviews, reset);

      this.allLoaded = data.reviews.length < 5;
      const btn = this.shadow.querySelector<HTMLElement>('.load-more');
      if (btn) btn.style.display = this.allLoaded ? 'none' : 'block';

      this.page++;

      // повідомляємо хост-сторінку
      this.dispatchEvent(new CustomEvent('reviews:loaded', {
        detail: { total: data.total, avgRating: data.avg_rating },
        bubbles: true,
        composed: true,
      }));
    } catch (err) {
      this.renderError();
    }
  }

  private renderRating(avg: number, total: number) {
    const ratingEl = this.shadow.querySelector('.rating')!;
    const stars = Array.from({ length: 5 }, (_, i) =>
      `<span class="star ${i < Math.round(avg) ? '' : 'empty'}">★</span>`
    ).join('');
    ratingEl.innerHTML = `${stars} <span>${avg.toFixed(1)} (${total} відзивів)</span>`;
    ratingEl.setAttribute('aria-label', `Рейтинг ${avg.toFixed(1)} із 5, ${total} відзивів`);
  }

  private renderReviews(reviews: Review[], reset: boolean) {
    const list = this.shadow.querySelector('.reviews-list')!;
    if (reset) list.innerHTML = '';

    const locale = this.dataset.locale ?? 'ua';
    const dateFormatter = new Intl.DateTimeFormat(locale, {
      year: 'numeric', month: 'long', day: 'numeric',
    });

    reviews.forEach(r => {
      const li = document.createElement('li');
      li.className = 'review-item';
      li.innerHTML = `
        <div class="review-author">${r.author}
          <time datetime="${r.date}" style="font-weight:400;color:#6b7280;margin-left:8px">
            ${dateFormatter.format(new Date(r.date))}
          </time>
        </div>
        <div class="review-text">${r.text}</div>
      `;
      list.appendChild(li);
    });
  }

  private renderError() {
    this.shadow.querySelector('.reviews-list')!.innerHTML =
      '<li style="color:#ef4444;padding:8px 0">Не вдалося завантажити відзиви</li>';
  }
}

customElements.define('review-widget', ReviewWidget);

Скрипт завантаження та гідрація

Один файл, який клієнт підключає один раз. Він сам реєструє елемент та обробляє вже існуючі в DOM екземпляри:

// src/loader.ts
import { ReviewWidget } from './ReviewWidget';

// Захист від подвійного підключення
if (!customElements.get('review-widget')) {
  customElements.define('review-widget', ReviewWidget);
}

// Для старих браузерів без customElements — мінімальний поліфіл
if (!window.customElements) {
  console.warn('[review-widget] Custom Elements не підтримуються');
}

Видання на CDN та цілісність ресурсів

<script
  src="https://cdn.example.com/[email protected]/widget.min.js"
  integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzavkYAlNwh38S"
  crossorigin="anonymous"
  async
></script>

SRI-хеш генерується при збірці та вказується у документації. Клієнт не оновить віджет випадково, лише коли намисне змінює версію в URL.

Часові рамки

Для одного вбудовуваного віджету середної складності — приблизно три тижні: один тиждень на дизайн API та архітектуру ізоляції, один тиждень на розроблення та тестування (включаючи кросбраузерну перевірку Shadow DOM), один тиждень на інтеграційне тестування з реальними хост-сайтами клієнтів.

Складність різко зростає, якщо віджет повинен підтримувати iframe-режим як резервний варіант для дуже старих браузерів або iframe-пісочниць. У такому випадку додайте до двох тижнів на postMessage-комунікацію між iframe та хостом.