Files
gh-secondsky-sap-skills-ski…/references/widget-templates.md
2025-11-30 08:55:27 +08:00

34 KiB

SAP SAC Custom Widget Templates

Ready-to-use templates for common custom widget patterns.


Table of Contents

  1. Basic Widget Template
  2. Widget with Styling Panel
  3. Data-Bound Widget
  4. Interactive Button Widget
  5. KPI Card Widget
  6. Widget with Builder Panel

Basic Widget Template

Minimal widget for getting started.

widget.json

{
  "id": "com.company.basicwidget",
  "version": "1.0.0",
  "name": "Basic Widget",
  "description": "A simple custom widget",
  "vendor": "Company Name",
  "license": "MIT",
  "icon": "",
  "webcomponents": [
    {
      "kind": "main",
      "tag": "basic-widget",
      "url": "/basic-widget.js",
      "integrity": "",
      "ignoreIntegrity": true
    }
  ],
  "properties": {
    "title": {
      "type": "string",
      "default": "Hello World"
    },
    "fontSize": {
      "type": "integer",
      "default": 16
    }
  },
  "methods": {},
  "events": {}
}

basic-widget.js

(function() {
  const template = document.createElement("template");
  template.innerHTML = `
    <style>
      :host {
        display: block;
        width: 100%;
        height: 100%;
        box-sizing: border-box;
      }
      .container {
        width: 100%;
        height: 100%;
        display: flex;
        align-items: center;
        justify-content: center;
        font-family: "72", Arial, sans-serif;
        background: #ffffff;
        border-radius: 4px;
        box-shadow: 0 1px 3px rgba(0,0,0,0.12);
      }
      .title {
        color: #32363a;
        text-align: center;
      }
    </style>
    <div class="container">
      <div class="title" id="title">Hello World</div>
    </div>
  `;

  class BasicWidget extends HTMLElement {
    constructor() {
      super();
      this._shadowRoot = this.attachShadow({ mode: "open" });
      this._shadowRoot.appendChild(template.content.cloneNode(true));
      this._props = {
        title: "Hello World",
        fontSize: 16
      };
    }

    connectedCallback() {
      this._render();
    }

    onCustomWidgetBeforeUpdate(changedProperties) {
      this._props = { ...this._props, ...changedProperties };
    }

    onCustomWidgetAfterUpdate(changedProperties) {
      this._render();
    }

    onCustomWidgetResize() {
      // Handle resize if needed
    }

    onCustomWidgetDestroy() {
      // Cleanup
    }

    _render() {
      const titleEl = this._shadowRoot.getElementById("title");
      titleEl.textContent = this._props.title;
      titleEl.style.fontSize = this._props.fontSize + "px";
    }

    // Property getters/setters
    get title() { return this._props.title; }
    set title(value) {
      this._props.title = value;
      this._render();
    }

    get fontSize() { return this._props.fontSize; }
    set fontSize(value) {
      this._props.fontSize = value;
      this._render();
    }
  }

  customElements.define("basic-widget", BasicWidget);
})();

Widget with Styling Panel

Widget with user-customizable properties via styling panel.

widget-styled.json

{
  "id": "com.company.styledwidget",
  "version": "1.0.0",
  "name": "Styled Widget",
  "description": "Widget with styling panel",
  "vendor": "Company Name",
  "license": "MIT",
  "icon": "",
  "webcomponents": [
    {
      "kind": "main",
      "tag": "styled-widget",
      "url": "/styled-widget.js",
      "integrity": "",
      "ignoreIntegrity": true
    },
    {
      "kind": "styling",
      "tag": "styled-widget-panel",
      "url": "/styled-widget-panel.js",
      "integrity": "",
      "ignoreIntegrity": true
    }
  ],
  "properties": {
    "title": {
      "type": "string",
      "default": "My Widget"
    },
    "backgroundColor": {
      "type": "string",
      "default": "#ffffff"
    },
    "textColor": {
      "type": "string",
      "default": "#333333"
    },
    "fontSize": {
      "type": "integer",
      "default": 18
    }
  },
  "methods": {},
  "events": {}
}

styled-widget.js

(function() {
  const template = document.createElement("template");
  template.innerHTML = `
    <style>
      :host {
        display: block;
        width: 100%;
        height: 100%;
      }
      .container {
        width: 100%;
        height: 100%;
        display: flex;
        align-items: center;
        justify-content: center;
        font-family: "72", Arial, sans-serif;
        border-radius: 4px;
        transition: all 0.3s ease;
      }
    </style>
    <div class="container" id="container">
      <span id="title"></span>
    </div>
  `;

  class StyledWidget extends HTMLElement {
    constructor() {
      super();
      this._shadowRoot = this.attachShadow({ mode: "open" });
      this._shadowRoot.appendChild(template.content.cloneNode(true));
      this._props = {
        title: "My Widget",
        backgroundColor: "#ffffff",
        textColor: "#333333",
        fontSize: 18
      };
    }

    connectedCallback() {
      this._render();
    }

    onCustomWidgetBeforeUpdate(changedProperties) {
      this._props = { ...this._props, ...changedProperties };
    }

    onCustomWidgetAfterUpdate(changedProperties) {
      this._render();
    }

    onCustomWidgetResize() {}
    onCustomWidgetDestroy() {}

    _render() {
      const container = this._shadowRoot.getElementById("container");
      const title = this._shadowRoot.getElementById("title");

      container.style.backgroundColor = this._props.backgroundColor;
      title.textContent = this._props.title;
      title.style.color = this._props.textColor;
      title.style.fontSize = this._props.fontSize + "px";
    }

    // Property getters/setters
    get title() { return this._props.title; }
    set title(value) { this._props.title = value; }

    get backgroundColor() { return this._props.backgroundColor; }
    set backgroundColor(value) { this._props.backgroundColor = value; }

    get textColor() { return this._props.textColor; }
    set textColor(value) { this._props.textColor = value; }

    get fontSize() { return this._props.fontSize; }
    set fontSize(value) { this._props.fontSize = value; }
  }

  customElements.define("styled-widget", StyledWidget);
})();

styled-widget-panel.js

(function() {
  const template = document.createElement("template");
  template.innerHTML = `
    <style>
      :host {
        display: block;
        font-family: "72", Arial, sans-serif;
        font-size: 12px;
      }
      .panel {
        padding: 12px;
      }
      .field {
        margin-bottom: 12px;
      }
      label {
        display: block;
        margin-bottom: 4px;
        color: #32363a;
        font-weight: 500;
      }
      input[type="text"],
      input[type="number"] {
        width: 100%;
        padding: 8px;
        border: 1px solid #89919a;
        border-radius: 4px;
        box-sizing: border-box;
      }
      input[type="color"] {
        width: 100%;
        height: 32px;
        padding: 2px;
        border: 1px solid #89919a;
        border-radius: 4px;
        cursor: pointer;
      }
    </style>
    <div class="panel">
      <div class="field">
        <label>Title</label>
        <input type="text" id="titleInput" />
      </div>
      <div class="field">
        <label>Background Color</label>
        <input type="color" id="bgColorInput" />
      </div>
      <div class="field">
        <label>Text Color</label>
        <input type="color" id="textColorInput" />
      </div>
      <div class="field">
        <label>Font Size (px)</label>
        <input type="number" id="fontSizeInput" min="8" max="72" />
      </div>
    </div>
  `;

  class StyledWidgetPanel extends HTMLElement {
    constructor() {
      super();
      this._shadowRoot = this.attachShadow({ mode: "open" });
      this._shadowRoot.appendChild(template.content.cloneNode(true));
      this._props = {};

      // Event listeners
      this._shadowRoot.getElementById("titleInput").addEventListener("change", (e) => {
        this._firePropertiesChanged({ title: e.target.value });
      });

      this._shadowRoot.getElementById("bgColorInput").addEventListener("input", (e) => {
        this._firePropertiesChanged({ backgroundColor: e.target.value });
      });

      this._shadowRoot.getElementById("textColorInput").addEventListener("input", (e) => {
        this._firePropertiesChanged({ textColor: e.target.value });
      });

      this._shadowRoot.getElementById("fontSizeInput").addEventListener("change", (e) => {
        this._firePropertiesChanged({ fontSize: parseInt(e.target.value) });
      });
    }

    _firePropertiesChanged(properties) {
      this.dispatchEvent(new CustomEvent("propertiesChanged", {
        detail: { properties }
      }));
    }

    onCustomWidgetBeforeUpdate(changedProperties) {
      this._props = { ...this._props, ...changedProperties };
    }

    onCustomWidgetAfterUpdate(changedProperties) {
      if (changedProperties.title !== undefined) {
        this._shadowRoot.getElementById("titleInput").value = changedProperties.title;
      }
      if (changedProperties.backgroundColor !== undefined) {
        this._shadowRoot.getElementById("bgColorInput").value = changedProperties.backgroundColor;
      }
      if (changedProperties.textColor !== undefined) {
        this._shadowRoot.getElementById("textColorInput").value = changedProperties.textColor;
      }
      if (changedProperties.fontSize !== undefined) {
        this._shadowRoot.getElementById("fontSizeInput").value = changedProperties.fontSize;
      }
    }
  }

  customElements.define("styled-widget-panel", StyledWidgetPanel);
})();

Data-Bound Widget

Widget that receives data from SAC models.

data-widget.json

{
  "id": "com.company.datawidget",
  "version": "1.0.0",
  "name": "Data Widget",
  "description": "Widget with data binding",
  "vendor": "Company Name",
  "license": "MIT",
  "icon": "",
  "webcomponents": [
    {
      "kind": "main",
      "tag": "data-widget",
      "url": "/data-widget.js",
      "integrity": "",
      "ignoreIntegrity": true
    }
  ],
  "properties": {
    "title": {
      "type": "string",
      "default": "Data Table"
    }
  },
  "methods": {
    "refresh": {
      "description": "Refresh the data display",
      "body": "this._refresh();"
    }
  },
  "events": {
    "onRowSelect": {
      "description": "Fired when a row is selected"
    }
  },
  "dataBindings": {
    "tableData": {
      "feeds": [
        {
          "id": "dimensions",
          "description": "Dimensions",
          "type": "dimension"
        },
        {
          "id": "measures",
          "description": "Measures",
          "type": "mainStructureMember"
        }
      ]
    }
  }
}

data-widget.js

(function() {
  const template = document.createElement("template");
  template.innerHTML = `
    <style>
      :host {
        display: block;
        width: 100%;
        height: 100%;
        overflow: auto;
      }
      .container {
        font-family: "72", Arial, sans-serif;
        font-size: 13px;
        padding: 8px;
      }
      .title {
        font-size: 16px;
        font-weight: 600;
        margin-bottom: 12px;
        color: #32363a;
      }
      table {
        width: 100%;
        border-collapse: collapse;
      }
      th, td {
        padding: 8px 12px;
        text-align: left;
        border-bottom: 1px solid #e5e5e5;
      }
      th {
        background: #f5f6f7;
        font-weight: 600;
        color: #32363a;
      }
      tr:hover {
        background: #fafafa;
        cursor: pointer;
      }
      .no-data {
        text-align: center;
        padding: 24px;
        color: #6a6d70;
      }
    </style>
    <div class="container">
      <div class="title" id="title">Data Table</div>
      <div id="tableContainer">
        <div class="no-data">No data available. Add data binding.</div>
      </div>
    </div>
  `;

  class DataWidget extends HTMLElement {
    constructor() {
      super();
      this._shadowRoot = this.attachShadow({ mode: "open" });
      this._shadowRoot.appendChild(template.content.cloneNode(true));
      this._props = { title: "Data Table" };
    }

    connectedCallback() {
      this._render();
    }

    onCustomWidgetBeforeUpdate(changedProperties) {
      this._props = { ...this._props, ...changedProperties };
    }

    onCustomWidgetAfterUpdate(changedProperties) {
      this._render();
    }

    onCustomWidgetResize() {}
    onCustomWidgetDestroy() {}

    _render() {
      this._shadowRoot.getElementById("title").textContent = this._props.title;
      this._renderTable();
    }

    _renderTable() {
      const container = this._shadowRoot.getElementById("tableContainer");

      // Check if data binding exists and has data
      if (!this.tableData || !this.tableData.data || this.tableData.data.length === 0) {
        container.innerHTML = '<div class="no-data">No data available. Add data binding.</div>';
        return;
      }

      const data = this.tableData.data;
      const metadata = this.tableData.metadata;

      // Build table header
      let headerHtml = '<tr>';
      const columns = [];

      // Add dimension columns
      if (metadata.dimensions) {
        Object.keys(metadata.dimensions).forEach((key, index) => {
          const dim = metadata.dimensions[key];
          headerHtml += `<th>${dim.description || key}</th>`;
          columns.push({ type: 'dimension', key: `dimensions_${index}` });
        });
      }

      // Add measure columns
      if (metadata.mainStructureMembers) {
        Object.keys(metadata.mainStructureMembers).forEach((key, index) => {
          const measure = metadata.mainStructureMembers[key];
          headerHtml += `<th>${measure.description || key}</th>`;
          columns.push({ type: 'measure', key: `measures_${index}` });
        });
      }
      headerHtml += '</tr>';

      // Build table rows
      let rowsHtml = '';
      data.forEach((row, rowIndex) => {
        rowsHtml += `<tr data-index="${rowIndex}">`;
        columns.forEach(col => {
          if (col.type === 'dimension') {
            const cell = row[col.key];
            rowsHtml += `<td>${cell ? cell.label : ''}</td>`;
          } else {
            const cell = row[col.key];
            rowsHtml += `<td>${cell ? this._formatNumber(cell.raw) : ''}</td>`;
          }
        });
        rowsHtml += '</tr>';
      });

      container.innerHTML = `<table><thead>${headerHtml}</thead><tbody>${rowsHtml}</tbody></table>`;

      // Add click handlers
      container.querySelectorAll('tbody tr').forEach(tr => {
        tr.addEventListener('click', () => {
          const index = parseInt(tr.dataset.index);
          this.dispatchEvent(new CustomEvent("onRowSelect", {
            detail: { rowIndex: index, rowData: data[index] }
          }));
        });
      });
    }

    _formatNumber(value) {
      if (typeof value === 'number') {
        return value.toLocaleString();
      }
      return value;
    }

    _refresh() {
      this._render();
    }

    // Property getter/setter
    get title() { return this._props.title; }
    set title(value) { this._props.title = value; }
  }

  customElements.define("data-widget", DataWidget);
})();

Interactive Button Widget

Widget with click events for script interaction.

button-widget.json

{
  "id": "com.company.buttonwidget",
  "version": "1.0.0",
  "name": "Button Widget",
  "description": "Interactive button with events",
  "vendor": "Company Name",
  "license": "MIT",
  "icon": "",
  "webcomponents": [
    {
      "kind": "main",
      "tag": "button-widget",
      "url": "/button-widget.js",
      "integrity": "",
      "ignoreIntegrity": true
    }
  ],
  "properties": {
    "label": {
      "type": "string",
      "default": "Click Me"
    },
    "buttonColor": {
      "type": "string",
      "default": "#0a6ed1"
    },
    "disabled": {
      "type": "boolean",
      "default": false
    }
  },
  "methods": {
    "click": {
      "description": "Programmatically click the button",
      "body": "this._click();"
    },
    "setDisabled": {
      "description": "Enable or disable the button",
      "parameters": [
        { "name": "isDisabled", "type": "boolean", "description": "Disabled state" }
      ],
      "body": "this._setDisabled(isDisabled);"
    }
  },
  "events": {
    "onClick": {
      "description": "Fired when button is clicked"
    }
  }
}

button-widget.js

(function() {
  const template = document.createElement("template");
  template.innerHTML = `
    <style>
      :host {
        display: block;
        width: 100%;
        height: 100%;
      }
      .container {
        width: 100%;
        height: 100%;
        display: flex;
        align-items: center;
        justify-content: center;
      }
      button {
        padding: 12px 24px;
        font-family: "72", Arial, sans-serif;
        font-size: 14px;
        font-weight: 500;
        color: #ffffff;
        border: none;
        border-radius: 4px;
        cursor: pointer;
        transition: all 0.2s ease;
      }
      button:hover:not(:disabled) {
        filter: brightness(1.1);
        transform: translateY(-1px);
      }
      button:active:not(:disabled) {
        transform: translateY(0);
      }
      button:disabled {
        opacity: 0.5;
        cursor: not-allowed;
      }
    </style>
    <div class="container">
      <button id="btn">Click Me</button>
    </div>
  `;

  class ButtonWidget extends HTMLElement {
    constructor() {
      super();
      this._shadowRoot = this.attachShadow({ mode: "open" });
      this._shadowRoot.appendChild(template.content.cloneNode(true));
      this._props = {
        label: "Click Me",
        buttonColor: "#0a6ed1",
        disabled: false
      };

      this._shadowRoot.getElementById("btn").addEventListener("click", () => {
        if (!this._props.disabled) {
          this.dispatchEvent(new Event("onClick"));
        }
      });
    }

    connectedCallback() {
      this._render();
    }

    onCustomWidgetBeforeUpdate(changedProperties) {
      this._props = { ...this._props, ...changedProperties };
    }

    onCustomWidgetAfterUpdate(changedProperties) {
      this._render();
    }

    onCustomWidgetResize() {}
    onCustomWidgetDestroy() {}

    _render() {
      const btn = this._shadowRoot.getElementById("btn");
      btn.textContent = this._props.label;
      btn.style.backgroundColor = this._props.buttonColor;
      btn.disabled = this._props.disabled;
    }

    _click() {
      if (!this._props.disabled) {
        this.dispatchEvent(new Event("onClick"));
      }
    }

    _setDisabled(isDisabled) {
      this._props.disabled = isDisabled;
      this._render();
    }

    // Property getters/setters
    get label() { return this._props.label; }
    set label(value) { this._props.label = value; }

    get buttonColor() { return this._props.buttonColor; }
    set buttonColor(value) { this._props.buttonColor = value; }

    get disabled() { return this._props.disabled; }
    set disabled(value) { this._props.disabled = value; }
  }

  customElements.define("button-widget", ButtonWidget);
})();

KPI Card Widget

Professional KPI display widget.

kpi-card.json

{
  "id": "com.company.kpicard",
  "version": "1.0.0",
  "name": "KPI Card",
  "description": "KPI display card with trend indicator",
  "vendor": "Company Name",
  "license": "MIT",
  "icon": "",
  "webcomponents": [
    {
      "kind": "main",
      "tag": "kpi-card",
      "url": "/kpi-card.js",
      "integrity": "",
      "ignoreIntegrity": true
    }
  ],
  "properties": {
    "title": { "type": "string", "default": "Revenue" },
    "value": { "type": "number", "default": 0 },
    "unit": { "type": "string", "default": "$" },
    "trend": { "type": "number", "default": 0 },
    "target": { "type": "number", "default": 0 },
    "thresholdGood": { "type": "number", "default": 100 },
    "thresholdBad": { "type": "number", "default": 80 }
  },
  "methods": {
    "setValue": {
      "description": "Set the KPI value",
      "parameters": [{ "name": "val", "type": "number" }],
      "body": "this._setValue(val);"
    }
  },
  "events": {
    "onClick": { "description": "Fired when card is clicked" }
  }
}

kpi-card.js

(function() {
  const template = document.createElement("template");
  template.innerHTML = `
    <style>
      :host {
        display: block;
        width: 100%;
        height: 100%;
      }
      .card {
        width: 100%;
        height: 100%;
        box-sizing: border-box;
        padding: 16px;
        background: #ffffff;
        border-radius: 8px;
        box-shadow: 0 2px 8px rgba(0,0,0,0.08);
        font-family: "72", Arial, sans-serif;
        cursor: pointer;
        transition: box-shadow 0.2s ease;
      }
      .card:hover {
        box-shadow: 0 4px 12px rgba(0,0,0,0.12);
      }
      .title {
        font-size: 13px;
        color: #6a6d70;
        margin-bottom: 8px;
        text-transform: uppercase;
        letter-spacing: 0.5px;
      }
      .value-row {
        display: flex;
        align-items: baseline;
        gap: 8px;
      }
      .value {
        font-size: 32px;
        font-weight: 600;
        color: #32363a;
      }
      .unit {
        font-size: 18px;
        color: #6a6d70;
      }
      .trend {
        display: flex;
        align-items: center;
        gap: 4px;
        margin-top: 8px;
        font-size: 13px;
      }
      .trend.positive { color: #107e3e; }
      .trend.negative { color: #bb0000; }
      .trend.neutral { color: #6a6d70; }
      .arrow { font-size: 16px; }
      .target {
        margin-top: 8px;
        font-size: 12px;
        color: #6a6d70;
      }
      .progress-bar {
        height: 4px;
        background: #e5e5e5;
        border-radius: 2px;
        margin-top: 4px;
        overflow: hidden;
      }
      .progress-fill {
        height: 100%;
        border-radius: 2px;
        transition: width 0.3s ease;
      }
      .progress-fill.good { background: #107e3e; }
      .progress-fill.warning { background: #e9730c; }
      .progress-fill.bad { background: #bb0000; }
    </style>
    <div class="card" id="card">
      <div class="title" id="title">Revenue</div>
      <div class="value-row">
        <span class="unit" id="unit">$</span>
        <span class="value" id="value">0</span>
      </div>
      <div class="trend" id="trend">
        <span class="arrow" id="arrow"></span>
        <span id="trendValue"></span>
      </div>
      <div class="target" id="targetSection">
        <span>Target: <span id="targetValue"></span></span>
        <div class="progress-bar">
          <div class="progress-fill" id="progressFill"></div>
        </div>
      </div>
    </div>
  `;

  class KpiCard extends HTMLElement {
    constructor() {
      super();
      this._shadowRoot = this.attachShadow({ mode: "open" });
      this._shadowRoot.appendChild(template.content.cloneNode(true));
      this._props = {
        title: "Revenue",
        value: 0,
        unit: "$",
        trend: 0,
        target: 0,
        thresholdGood: 100,
        thresholdBad: 80
      };

      this._shadowRoot.getElementById("card").addEventListener("click", () => {
        this.dispatchEvent(new Event("onClick"));
      });
    }

    connectedCallback() {
      this._render();
    }

    onCustomWidgetBeforeUpdate(changedProperties) {
      this._props = { ...this._props, ...changedProperties };
    }

    onCustomWidgetAfterUpdate(changedProperties) {
      this._render();
    }

    onCustomWidgetResize() {}
    onCustomWidgetDestroy() {}

    _render() {
      const p = this._props;

      this._shadowRoot.getElementById("title").textContent = p.title;
      this._shadowRoot.getElementById("unit").textContent = p.unit;
      this._shadowRoot.getElementById("value").textContent = this._formatNumber(p.value);

      // Trend
      const trendEl = this._shadowRoot.getElementById("trend");
      const arrowEl = this._shadowRoot.getElementById("arrow");
      const trendValueEl = this._shadowRoot.getElementById("trendValue");

      if (p.trend > 0) {
        trendEl.className = "trend positive";
        arrowEl.textContent = "↑";
        trendValueEl.textContent = `+${p.trend}%`;
      } else if (p.trend < 0) {
        trendEl.className = "trend negative";
        arrowEl.textContent = "↓";
        trendValueEl.textContent = `${p.trend}%`;
      } else {
        trendEl.className = "trend neutral";
        arrowEl.textContent = "→";
        trendValueEl.textContent = "0%";
      }

      // Target progress
      const targetSection = this._shadowRoot.getElementById("targetSection");
      if (p.target > 0) {
        targetSection.style.display = "block";
        this._shadowRoot.getElementById("targetValue").textContent =
          p.unit + this._formatNumber(p.target);

        const percentage = Math.min((p.value / p.target) * 100, 100);
        const progressFill = this._shadowRoot.getElementById("progressFill");
        progressFill.style.width = percentage + "%";

        if (percentage >= p.thresholdGood) {
          progressFill.className = "progress-fill good";
        } else if (percentage >= p.thresholdBad) {
          progressFill.className = "progress-fill warning";
        } else {
          progressFill.className = "progress-fill bad";
        }
      } else {
        targetSection.style.display = "none";
      }
    }

    _formatNumber(num) {
      if (num >= 1000000) {
        return (num / 1000000).toFixed(1) + "M";
      } else if (num >= 1000) {
        return (num / 1000).toFixed(1) + "K";
      }
      return num.toLocaleString();
    }

    _setValue(val) {
      this._props.value = val;
      this._render();
    }

    // Property getters/setters
    get title() { return this._props.title; }
    set title(v) { this._props.title = v; }
    get value() { return this._props.value; }
    set value(v) { this._props.value = v; }
    get unit() { return this._props.unit; }
    set unit(v) { this._props.unit = v; }
    get trend() { return this._props.trend; }
    set trend(v) { this._props.trend = v; }
    get target() { return this._props.target; }
    set target(v) { this._props.target = v; }
    get thresholdGood() { return this._props.thresholdGood; }
    set thresholdGood(v) { this._props.thresholdGood = v; }
    get thresholdBad() { return this._props.thresholdBad; }
    set thresholdBad(v) { this._props.thresholdBad = v; }
  }

  customElements.define("kpi-card", KpiCard);
})();

Widget with Builder Panel

Widget with design-time configuration via builder panel.

builder-widget.json

{
  "id": "com.company.builderwidget",
  "version": "1.0.0",
  "name": "Builder Widget",
  "description": "Widget with builder panel configuration",
  "vendor": "Company Name",
  "license": "MIT",
  "icon": "",
  "webcomponents": [
    {
      "kind": "main",
      "tag": "builder-widget",
      "url": "/builder-widget.js",
      "integrity": "",
      "ignoreIntegrity": true
    },
    {
      "kind": "builder",
      "tag": "builder-widget-config",
      "url": "/builder-widget-config.js",
      "integrity": "",
      "ignoreIntegrity": true
    }
  ],
  "properties": {
    "chartType": {
      "type": "string",
      "default": "bar"
    },
    "showLegend": {
      "type": "boolean",
      "default": true
    },
    "orientation": {
      "type": "string",
      "default": "vertical"
    }
  },
  "methods": {},
  "events": {},
  "dataBindings": {
    "chartData": {
      "feeds": [
        { "id": "category", "description": "Category", "type": "dimension" },
        { "id": "value", "description": "Value", "type": "mainStructureMember" }
      ]
    }
  }
}

builder-widget-config.js

Builder panel for data visualization configuration. Provides design-time controls for chart type, legend visibility, orientation, and data feed selection.

(function() {
  // Builder Panel Web Component for Widget Configuration
  const template = document.createElement("template");
  template.innerHTML = `
    <style>
      :host {
        display: block;
        font-family: "72", Arial, sans-serif;
        font-size: 12px;
      }
      .panel {
        padding: 12px;
      }
      .section {
        margin-bottom: 16px;
        padding-bottom: 12px;
        border-bottom: 1px solid #e5e5e5;
      }
      .section-title {
        font-weight: 600;
        color: #32363a;
        margin-bottom: 8px;
      }
      .field {
        margin-bottom: 12px;
      }
      label {
        display: block;
        margin-bottom: 4px;
        color: #32363a;
        font-weight: 500;
      }
      select, input[type="text"] {
        width: 100%;
        padding: 8px;
        border: 1px solid #89919a;
        border-radius: 4px;
        box-sizing: border-box;
        background: #fff;
      }
      .checkbox-field {
        display: flex;
        align-items: center;
        gap: 8px;
      }
      .checkbox-field label {
        margin-bottom: 0;
      }
      .help-text {
        font-size: 11px;
        color: #6a6d70;
        margin-top: 4px;
      }
    </style>
    <div class="panel">
      <!-- Chart Configuration Section -->
      <div class="section">
        <div class="section-title">Chart Configuration</div>
        <div class="field">
          <label for="chartTypeSelect">Chart Type</label>
          <select id="chartTypeSelect">
            <option value="bar">Bar Chart</option>
            <option value="column">Column Chart</option>
            <option value="line">Line Chart</option>
            <option value="area">Area Chart</option>
            <option value="pie">Pie Chart</option>
          </select>
        </div>
        <div class="field">
          <label for="orientationSelect">Orientation</label>
          <select id="orientationSelect">
            <option value="vertical">Vertical</option>
            <option value="horizontal">Horizontal</option>
          </select>
          <div class="help-text">Applies to bar/column charts</div>
        </div>
        <div class="field checkbox-field">
          <input type="checkbox" id="showLegendCheckbox" />
          <label for="showLegendCheckbox">Show Legend</label>
        </div>
      </div>

      <!-- Data Binding Info Section -->
      <div class="section">
        <div class="section-title">Data Configuration</div>
        <div class="field">
          <label>Category Feed</label>
          <input type="text" id="categoryFeed" readonly placeholder="Bind in Builder Panel" />
          <div class="help-text">Configure via Data Binding panel</div>
        </div>
        <div class="field">
          <label>Value Feed</label>
          <input type="text" id="valueFeed" readonly placeholder="Bind in Builder Panel" />
          <div class="help-text">Configure via Data Binding panel</div>
        </div>
      </div>
    </div>
  `;

  class BuilderWidgetConfig extends HTMLElement {
    constructor() {
      super();
      this._shadowRoot = this.attachShadow({ mode: "open" });
      this._shadowRoot.appendChild(template.content.cloneNode(true));
      this._props = {
        chartType: "bar",
        showLegend: true,
        orientation: "vertical"
      };

      // Event listeners for configuration changes
      this._shadowRoot.getElementById("chartTypeSelect").addEventListener("change", (e) => {
        this._firePropertiesChanged({ chartType: e.target.value });
      });

      this._shadowRoot.getElementById("orientationSelect").addEventListener("change", (e) => {
        this._firePropertiesChanged({ orientation: e.target.value });
      });

      this._shadowRoot.getElementById("showLegendCheckbox").addEventListener("change", (e) => {
        this._firePropertiesChanged({ showLegend: e.target.checked });
      });
    }

    // Dispatch property changes to SAC framework
    _firePropertiesChanged(properties) {
      this.dispatchEvent(new CustomEvent("propertiesChanged", {
        detail: { properties }
      }));
    }

    // Lifecycle: Called before properties are updated
    onCustomWidgetBeforeUpdate(changedProperties) {
      this._props = { ...this._props, ...changedProperties };
    }

    // Lifecycle: Called after properties are updated - sync UI
    onCustomWidgetAfterUpdate(changedProperties) {
      if (changedProperties.chartType !== undefined) {
        this._shadowRoot.getElementById("chartTypeSelect").value = changedProperties.chartType;
      }
      if (changedProperties.orientation !== undefined) {
        this._shadowRoot.getElementById("orientationSelect").value = changedProperties.orientation;
      }
      if (changedProperties.showLegend !== undefined) {
        this._shadowRoot.getElementById("showLegendCheckbox").checked = changedProperties.showLegend;
      }
    }

    // Data binding info (read-only display)
    // NOTE: Actual data feed configuration is handled by SAC's native Builder Panel.
    // This section displays current binding status for reference.
    setDataBindingInfo(bindingInfo) {
      if (bindingInfo?.chartData) {
        const feeds = bindingInfo.chartData.feeds || {};
        if (feeds.category) {
          this._shadowRoot.getElementById("categoryFeed").value = feeds.category.label || "Bound";
        }
        if (feeds.value) {
          this._shadowRoot.getElementById("valueFeed").value = feeds.value.label || "Bound";
        }
      }
    }

    // Property getters/setters
    get chartType() { return this._props.chartType; }
    set chartType(v) { this._props.chartType = v; }
    get showLegend() { return this._props.showLegend; }
    set showLegend(v) { this._props.showLegend = v; }
    get orientation() { return this._props.orientation; }
    set orientation(v) { this._props.orientation = v; }
  }

  customElements.define("builder-widget-config", BuilderWidgetConfig);
})();

Note: The builder panel provides design-time configuration UI. Data feed selection (category/value) is typically configured through SAC's native data binding interface rather than custom controls, as SAC handles the model/dimension selection.


Deployment Checklist

For each template:

  1. Update id with your reverse domain notation
  2. Update vendor with your company name
  3. Host files (SAC, GitHub Pages, or web server)
  4. Update url properties with actual URLs
  5. Test in SAC story/application
  6. Generate integrity hashes for production
  7. Set ignoreIntegrity: false for production

Source Documentation:

Last Updated: 2025-11-22