# SAP SAC Custom Widget Templates Ready-to-use templates for common custom widget patterns. --- ## Table of Contents 1. [Basic Widget Template](#basic-widget-template) 2. [Widget with Styling Panel](#widget-with-styling-panel) 3. [Data-Bound Widget](#data-bound-widget) 4. [Interactive Button Widget](#interactive-button-widget) 5. [KPI Card Widget](#kpi-card-widget) 6. [Widget with Builder Panel](#widget-with-builder-panel) --- ## Basic Widget Template Minimal widget for getting started. ### widget.json ```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 ```javascript (function() { const template = document.createElement("template"); template.innerHTML = `
Hello World
`; 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 ```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 ```javascript (function() { const template = document.createElement("template"); template.innerHTML = `
`; 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 ```javascript (function() { const template = document.createElement("template"); template.innerHTML = `
`; 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 ```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 ```javascript (function() { const template = document.createElement("template"); template.innerHTML = `
Data Table
No data available. Add data binding.
`; 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 = '
No data available. Add data binding.
'; return; } const data = this.tableData.data; const metadata = this.tableData.metadata; // Build table header let headerHtml = ''; const columns = []; // Add dimension columns if (metadata.dimensions) { Object.keys(metadata.dimensions).forEach((key, index) => { const dim = metadata.dimensions[key]; headerHtml += `${dim.description || key}`; 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 += `${measure.description || key}`; columns.push({ type: 'measure', key: `measures_${index}` }); }); } headerHtml += ''; // Build table rows let rowsHtml = ''; data.forEach((row, rowIndex) => { rowsHtml += ``; columns.forEach(col => { if (col.type === 'dimension') { const cell = row[col.key]; rowsHtml += `${cell ? cell.label : ''}`; } else { const cell = row[col.key]; rowsHtml += `${cell ? this._formatNumber(cell.raw) : ''}`; } }); rowsHtml += ''; }); container.innerHTML = `${headerHtml}${rowsHtml}
`; // 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 ```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 ```javascript (function() { const template = document.createElement("template"); template.innerHTML = `
`; 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 ```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 ```javascript (function() { const template = document.createElement("template"); template.innerHTML = `
Revenue
$ 0
Target:
`; 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 ```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. ```javascript (function() { // Builder Panel Web Component for Widget Configuration const template = document.createElement("template"); template.innerHTML = `
Chart Configuration
Applies to bar/column charts
Data Configuration
Configure via Data Binding panel
Configure via Data Binding panel
`; 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**: - [SAP Custom Widget Developer Guide](https://help.sap.com/doc/c813a28922b54e50bd2a307b099787dc/release/en-US/CustomWidgetDevGuide_en.pdf) - [SAP Samples Repository](https://github.com/SAP-samples/analytics-cloud-datasphere-community-content/tree/main/SAC_Custom_Widgets) **Last Updated**: 2025-11-22