Hey Neutrinos community! 
Ever had a table full of phone numbers and emails that users just have to manually copy-paste? Not great UX. Here’s how to make them clickable links — tel: for phones, mailto: for emails — across both data tables inside panels and read-only text/input fields, even when they’re buried inside shadow roots. ![]()
This works entirely through a custom code block (no component changes needed). Let’s walk through it!
What We’re Building
-
mailto:links on email columns in tables -
tel:links on phone columns in tables -
Same for read-only
alpha-text-fieldandalpha-key-valueinput fields -
Auto-detects columns by header name matching (no hardcoded indexes)
-
Survives pagination and table re-renders via MutationObserver
-
Works across shadow DOM (handles
alpha-renderer,alpha-inbox, etc.)
STEP 1 — Understand the Config Structure 
Everything is driven by a simple config array. You declare:
-
Which panel ID contains the table or fields
-
Which column keywords to match (e.g.
"email","phone") -
What type of link to apply (
"email"or"phone")
const panelConfigs = [
{
panelId: "YOUR_PANEL_ID_HERE",
columns: {
email: { match: "email", type: "email" },
phone: { match: "phone", type: "phone" },
}
},
// Add more panels as needed...
];
![]()
matchis checked against the column header text (case-insensitive, partial match). So a header like"Contact Email"will match"email", and"Cell Phone"will match"phone".
Separate Config for Text Fields
If you have read-only input fields (not tables) showing email/phone values, use a separate config:
const textFieldConfigs = [
{
panelId: "YOUR_DETAIL_PANEL_ID",
columns: {
email: { match: "email", type: "email" },
phone: { match: "cell", type: "phone" }, // matches "Cell Phone" label
contact: { match: "contact", type: "phone" } // matches "Contact No" label
}
}
];
The
matchvalue is checked against the field label, not the value itself.
Inbox / Special Table Config
If you have an alpha-inbox component with its own shadow root:
const inboxConfigs = [
{
selector: "alpha-inbox",
columns: {
email: { match: "email", type: "email" },
phone: { match: "phone", type: "phone" },
}
}
];
STEP 2 — The Link Factory 
This function creates the actual <a> element depending on the type:
function createLink(type, value) {
const link = document.createElement("a");
link.style.cssText = `color:#0066cc; text-decoration:underline; cursor:pointer;`;
if (type === "email") {
link.href = `mailto:${value}`;
link.textContent = value;
} else if (type === "phone") {
const digitsOnly = value.replace(/\D/g, '');
link.href = `tel:+1${digitsOnly}`; // ✏️ Change country code if needed
link.textContent = value;
}
return link;
}
Customise the country code on the
tel:line — change+1to your country code (e.g.+91for India,+44for UK).
STEP 3 — Apply Links to Tables 
This function scans a table’s <thead> to find matching columns, then replaces cell content with links:
function applyHyperlinksToTable(table, columns) {
const headers = Array.from(table.querySelectorAll("thead tr th"));
const colIndexMap = {};
// Map column keys to their index by matching header text
headers.forEach((th, index) => {
const text = th.textContent.trim().toLowerCase();
Object.entries(columns).forEach(([key, config]) => {
if (text.includes(config.match)) {
colIndexMap[key] = index;
}
});
});
const allRows = table.querySelectorAll("tbody tr");
allRows.forEach((tr) => {
const cells = tr.querySelectorAll("td");
Object.entries(colIndexMap).forEach(([key, colIndex]) => {
const td = cells[colIndex];
if (!td) return;
const span = td.querySelector("span");
const currentValue = span ? span.textContent.trim() : td.textContent.trim();
if (!currentValue) return;
// ✅ Skip if already linkified with same value (prevents re-render flicker)
if (td.dataset.linkValue === currentValue) return;
const link = createLink(columns[key].type, currentValue);
if (span) {
span.innerHTML = "";
span.appendChild(link);
} else {
td.innerHTML = "";
td.appendChild(link);
}
td.dataset.linkValue = currentValue; // mark as done
});
});
}
STEP 4 — Survive Pagination with MutationObserver 
Tables re-render on pagination. Attach an observer so links get re-applied whenever rows change:
function observeTableChanges(table, columns) {
if (table.dataset.observerAttached === "true") return; // don't attach twice
const tbody = table.querySelector("tbody");
if (!tbody) return;
const observer = new MutationObserver(() => {
applyHyperlinksToTable(table, columns);
});
observer.observe(tbody, { childList: true });
table.dataset.observerAttached = "true";
}
Call
observeTableChanges(table, columns)right afterapplyHyperlinksToTable(table, columns)to wire it up.
STEP 5 — Apply Links to Read-Only Input Fields 
For alpha-text-field components rendered in detail/form panels:
function applyLinksToTextFields(root, columns) {
const allElements = root.querySelectorAll("*");
const emailMatches = Object.values(columns)
.filter(c => c.type === "email").map(c => c.match.toLowerCase());
const phoneMatches = Object.values(columns)
.filter(c => c.type === "phone").map(c => c.match.toLowerCase());
allElements.forEach((el) => {
if (!el.shadowRoot) return;
// Case 1: Readonly field rendered as alpha-key-value
const keyValueEl = el.shadowRoot.querySelector("alpha-key-value");
if (keyValueEl) {
const labelText = (keyValueEl.getAttribute("key") || "").toLowerCase();
const value = (keyValueEl.getAttribute("value") || "").trim();
if (!value) return;
let type = null;
if (emailMatches.some(m => labelText.includes(m))) type = "email";
if (phoneMatches.some(m => labelText.includes(m))) type = "phone";
if (!type) return;
applyLinkToKeyValue(keyValueEl, type, value);
return;
}
// Case 2: Disabled/readonly input with a label
const input = el.shadowRoot.querySelector("input");
const label = el.shadowRoot.querySelector("label");
if (!input || !label) return;
if (!input.hasAttribute("readonly") && !input.disabled) return;
const value = input.value?.trim();
const labelText = label.textContent.toLowerCase();
if (!value) return;
let type = null;
if (emailMatches.some(m => labelText.includes(m))) type = "email";
if (phoneMatches.some(m => labelText.includes(m))) type = "phone";
if (!type) return;
// Replace input visually with a link
input.style.display = "none";
if (!label.nextSibling?.tagName === "A") {
const link = createLink(type, value);
label.insertAdjacentElement("afterend", link);
}
});
}
Styling alpha-key-value Fields to Match the Platform Look
When applying a link inside alpha-key-value, you’ll want to keep the floating label + border style consistent with the rest of the UI:
function applyLinkToKeyValue(keyValueEl, type, value) {
if (!keyValueEl.shadowRoot) return;
const valueSpan = keyValueEl.shadowRoot.querySelector("span.value");
const keySpan = keyValueEl.shadowRoot.querySelector("span.key");
const containerDiv = keyValueEl.shadowRoot.querySelector("div.alpha-key-value");
if (!valueSpan || !containerDiv) return;
if (containerDiv.dataset.linkApplied === "true") return; // already done
containerDiv.dataset.linkApplied = "true";
// Match the platform's floating label field style
containerDiv.style.cssText = `
position: relative;
border: 1px solid var(--alpha-primary-inactive-color);
border-radius: 8px;
padding: 0 12px;
background: #fff;
height: 40px;
display: flex;
flex-direction: column;
justify-content: flex-end;
width: 100%;
box-sizing: border-box;
`;
if (keySpan) {
keySpan.style.cssText = `
position: absolute;
top: -9px;
left: 10px;
font-size: 14px;
color: var(--alpha-primary-color);
background: #fff;
padding: 0 3px;
white-space: nowrap;
`;
}
const link = createLink(type, value);
link.style.fontSize = "14px";
valueSpan.innerHTML = "";
valueSpan.appendChild(link);
}
STEP 6 — Shadow DOM Traversal Helpers 
Alpha components use shadow roots heavily. You’ll need these two helpers to find panels and tables:
// Find an element by ID anywhere in the shadow DOM tree
function deepFindById(root, id) {
const direct = root.getElementById
? root.getElementById(id)
: root.querySelector(`[id='${id}']`);
if (direct) return direct;
const all = root.querySelectorAll ? root.querySelectorAll("*") : [];
for (const el of all) {
if (el.shadowRoot) {
const found = deepFindById(el.shadowRoot, id);
if (found) return found;
}
}
return null;
}
// Find the <table> inside a panel (checks shadow roots recursively)
function findTableInPanel(panelEl) {
if (panelEl.shadowRoot) {
const shadowTable = panelEl.shadowRoot.querySelector("table");
if (shadowTable) return shadowTable;
for (const el of panelEl.shadowRoot.querySelectorAll("*")) {
if (el.shadowRoot) {
const t = el.shadowRoot.querySelector("table");
if (t) return t;
}
}
}
for (const el of panelEl.querySelectorAll("*")) {
if (el.shadowRoot) {
const t = el.shadowRoot.querySelector("table");
if (t) return t;
}
}
return null;
}
STEP 7 — The Main Runner Loop 
Wire everything together with a polling interval (handles lazy-loaded panels and route changes):
let lastTables = new Map();
function smartRunner() {
const renderer = document.querySelector("alpha-renderer");
if (!renderer?.shadowRoot) return;
// Apply to panel tables
panelConfigs.forEach(({ panelId, columns }) => {
const panel = deepFindById(renderer.shadowRoot, panelId);
if (!panel) return;
const table = findTableInPanel(panel);
if (!table) return;
const prevTable = lastTables.get(panelId);
if (prevTable !== table) {
lastTables.set(panelId, table);
setTimeout(() => applyHyperlinksToTable(table, columns), 300);
} else {
applyHyperlinksToTable(table, columns);
}
});
// Apply to text/input fields
textFieldConfigs.forEach(({ panelId, columns }) => {
const panel = deepFindById(renderer.shadowRoot, panelId);
if (!panel) return;
applyLinksToTextFields(panel, columns);
});
}
// Run every second — lightweight since we skip already-linked cells
setInterval(smartRunner, 1000);
The
td.dataset.linkValuecheck insideapplyHyperlinksToTableensures already-linked cells are skipped, keeping the interval cheap.
Quick Checklist Before You Deploy
-
[ ] Correct panel IDs added to
panelConfigsandtextFieldConfigs -
[ ] Column
matchstrings reflect actual header/label text in your app -
[ ] Country code in
createLinkis correct for your region -
[ ]
observeTableChangescalled for any paginated tables -
[ ] Tested on both table view and detail/form view
-
[ ]
DEBUGflag set tofalsebefore production
Summary: Which Config Does What?
| Config | Targets | How it matches |
|---|---|---|
panelConfigs |
<table> inside a panel |
Column header text |
textFieldConfigs |
alpha-text-field / alpha-key-value |
Field label text |
inboxConfigs |
alpha-inbox shadow root tables |
Column header text |
Common Gotchas
Links disappear after pagination? → Make sure observeTableChanges is called after the first applyHyperlinksToTable.
Links not appearing at all? → Check your panel ID is correct. Enable const DEBUG = true; temporarily to see console logs.
Phone links not working on desktop? → That’s expected — tel: links only auto-open on mobile/devices with a dialler. They’ll still display as styled links on desktop.
Label/value not found in alpha-key-value? → The component may not have fully rendered yet — the 1s interval will catch it on the next tick.
That’s the full setup! Once you drop this into your custom code block, every table and detail panel with email/phone data gets instant clickable links — with zero component changes. ![]()
Drop any questions below, happy to help. And if this saved you time, give it a
!
Happy building! ![]()
— Posted in Community / Tips, Tricks & Custom Code