All skills
secondsky avatar

/sap-sac-custom-widget

@4d87d99
by Eddiesecondsky/sap-skills456 stars
120

SAP Analytics Cloud (SAC) Custom Widget development. Use when building custom visualizations, extending SAC with Web Components, or creating Widget Add-Ons. Covers JSON metadata, JavaScript Web Components, lifecycle functions, data binding with feeds, styling/builder panels, property/event/method definitions, third-party library integration, hosting, security, performance, and debugging. Includes Widget Add-On feature (QRC Q4 2023+) and templates for widgets, charts, and KPI cards.

Use this Skill: https://skilld.dev/gh/secondsky/sap-skills/sap-sac-custom-widget

This session only. Nothing lands on disk.

referenceswidget-templates.md

≈8.9k tokens on demand. Your agent reads this file only when SKILL.md points to it.

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"
    },
    "refreshToken": {
      "type": "integer",
      "default": 0
    }
  },
  "methods": {
    "refresh": {
      "description": "Refresh the data display",
      "body": "this.refreshToken = this.refreshToken + 1;"
    }
  },
  "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
    },
    "clickRequest": {
      "type": "integer",
      "default": 0
    }
  },
  "methods": {
    "click": {
      "description": "Programmatically click the button",
      "body": "this.clickRequest = this.clickRequest + 1;"
    },
    "setDisabled": {
      "description": "Enable or disable the button",
      "parameters": [
        { "name": "isDisabled", "type": "boolean", "description": "Disabled state" }
      ],
      "body": "this.disabled = 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.value = 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
  8. Confirm every manifest property changes observable runtime behavior
  9. Test the final component files, not only shared source helpers
  10. Test property updates, touch input, focus retention, and cleanup
  11. Use a visible browser for layout, fonts, resize, and hit testing
  12. Keep widget.json and the Resource-ZIP as separate upload artifacts

Source Documentation:

Last Updated: 2025-11-22

Source: SKILL.md on GitHub

1 alert16d5 checks · Risk SAFE
  • Gen Agent Trust Hub16d

    The skill provides a comprehensive environment and templates for developing SAP Analytics Cloud (SAC) Custom Widgets. It promotes robust security best practices, including subresource integrity (SRI), XSS prevention via input sanitization, and proper Shadow DOM encapsulation. It includes local tools for scaffold generation and iteration that operate on the user's machine without requiring external packages or services.

  • Socket16d

    No alerts

  • Snyk16d

    Risk: LOW · No issues

  • Runlayer6mo

    3/16 files flagged

  • ZeroLeaks5mo

    2 findings · Score: 80/100

Signed by skilld at 4d87d99. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 weeks ago.

Activeupdated last month
What it can do
Reads files Runs commands Network
All 3 allowed tools
ReadBashWebFetch
Other metadata
metadata
{
  "maintainer": "Eduard Jiglau",
  "maintainer_email": "hello@sap-ai-skills.com",
  "website": "https://sap-ai-skills.com",
  "version": "2.4.1",
  "last_verified": "2026-06-12",
  "sac_version": "2026.8",
  "errors_prevented": "40+",
  "official_docs": [
    "https://help.sap.com/docs/SAP_ANALYTICS_CLOUD/0ac8c6754ff84605a4372468d002f2bf/75311f67527c41638ceb89af9cd8af3e.html",
    "https://help.sap.com/doc/c813a28922b54e50bd2a307b099787dc/release/en-US/CustomWidgetDevGuide_en.pdf"
  ],
  "samples_repo": "https://github.com/SAP-samples/analytics-cloud-datasphere-community-content/tree/main/SAC_Custom_Widgets",
  "keywords": [
    "sap analytics cloud",
    "sac custom widget",
    "web component sac",
    "json metadata widget",
    "widget lifecycle functions",
    "onCustomWidgetBeforeUpdate",
    "onCustomWidgetAfterUpdate",
    "onCustomWidgetResize",
    "onCustomWidgetDestroy",
    "sac data binding",
    "dataBindings feeds",
    "styling panel widget",
    "builder panel widget",
    "sac echarts integration",
    "sac d3js integration",
    "third party library sac",
    "widget hosting sac",
    "integrity hash widget",
    "sha256 integrity",
    "widget security cors",
    "sac widget debugging",
    "sac analytics designer widget",
    "optimized story experience widget",
    "sac widget api",
    "widget add-on",
    "sac script api widget",
    "shadow dom web component",
    "sac tooltip customization",
    "plot area addon",
    "sac resource zip upload",
    "root relative widget url",
    "resource file upload",
    "builder focus collapse state",
    "self contained component js",
    "resource zip artifact naming",
    "chat download artifacts"
  ]
}

README badge

README badge for secondsky/sap-skills/sap-sac-custom-widget