How to Add Clickable Phone & Email Links in Alpha / DataFabric UI Tables and Input Fields

Hey Neutrinos community! :waving_hand:

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 linkstel: 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. :light_bulb:

This works entirely through a custom code block (no component changes needed). Let’s walk through it!


:compass: What We’re Building

  • mailto: links on email columns in tables

  • tel: links on phone columns in tables

  • Same for read-only alpha-text-field and alpha-key-value input 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 :gear:

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...
];

:pushpin: match is 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
    }
  }
];

:light_bulb: The match value 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 :link:

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;
}

:pencil: Customise the country code on the tel: line — change +1 to your country code (e.g. +91 for India, +44 for UK).


STEP 3 — Apply Links to Tables :clipboard:

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 :counterclockwise_arrows_button:

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";
}

:light_bulb: Call observeTableChanges(table, columns) right after applyHyperlinksToTable(table, columns) to wire it up.


STEP 5 — Apply Links to Read-Only Input Fields :memo:

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 :hole:

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 :stopwatch:

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);

:white_check_mark: The td.dataset.linkValue check inside applyHyperlinksToTable ensures already-linked cells are skipped, keeping the interval cheap.


:white_check_mark: Quick Checklist Before You Deploy

  • [ ] Correct panel IDs added to panelConfigs and textFieldConfigs

  • [ ] Column match strings reflect actual header/label text in your app

  • [ ] Country code in createLink is correct for your region

  • [ ] observeTableChanges called for any paginated tables

  • [ ] Tested on both table view and detail/form view

  • [ ] DEBUG flag set to false before production


:card_index_dividers: 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

:speech_balloon: 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. :tada:

Drop any questions below, happy to help. And if this saved you time, give it a :+1:!

Happy building! :high_voltage:

— Posted in Community / Tips, Tricks & Custom Code

1 Like

Hey Folks,

Above code has some issues with the performance of the UI,

Please find the updated optimised code below:

/*

* Write your custom code here.

* You can define Multiple Functions and Invoke them.

* Use console for Execution Logs and Debugging.

*/

const DEBUG = false;

// :white_check_mark: CONFIG — just add panel IDs and which columns need hyperlinks

const panelConfigs = [

{

panelId: "1bd3339f-da23-4fc4-9762-d4b8ea798ece",

columns: {

  email: { match: "email", type: "email" },

  phone: { match: "phone", type: "phone" },

}

},

{

panelId: "befef896-7b96-463c-aa12-d79d26d80254",

columns: {

  email: { match: "email", type: "email" },

  phone: { match: "phone", type: "phone" },

}

},

{

panelId: "e0da5370-b020-4628-ae02-753510f83e51",

columns: {

  email: { match: "email", type: "email" },

  phone: { match: "phone", type: "phone" },

}

},

{

panelId: "38929b58-704e-4974-9148-91709fea530b",

columns: {

  email: { match: "email", type: "email" },

  phone: { match: "phone", type: "phone" },

}

},

{

panelId: "e9a59fef-55c2-42be-8e48-49b6802b8a64",

columns: {

  email: { match: "email", type: "email" },

  phone: { match: "phone", type: "phone" },

}

},

{

panelId: "1324c2b2-8ae1-42dc-b1cf-10ac8bde7dcd",

columns: {

  email: { match: "email", type: "email" }

}

},

{

panelId: "2d79431f-f9b9-4d1a-97b9-cab9559b978d",

columns: {

  phone: { match: "phone", type: "phone" }

}

},

{

panelId: "251ee550-be97-4bde-bc67-697d69c4ec7e",

columns: {

  email: { match: "email", type: "email" },

  phone: { match: "phone", type: "phone" },

}

},

{

panelId: "e1d0d500-5c71-4526-a109-af12addb76ed",

columns: {

  email: { match: "email", type: "email" },

  phone: { match: "phone", type: "phone" },

}

},

{

panelId: "a9b313db-2b96-4770-b249-c71f7f041f1e",

columns: {

  email: { match: "email", type: "email" },

  phone: { match: "phone", type: "phone" },

}

},

{

panelId: "560f68c0-aee4-403e-a22b-835b27c180c7",

columns: {

  email: { match: "email", type: "email" },

  phone: { match: "phone", type: "phone" },

}

},

{

panelId: "c7ee3f69-c877-440c-ade1-c19b0c363930",

columns: {

  email: { match: "email", type: "email" },

  phone: { match: "phone", type: "phone" },

}

},

];

const textFieldConfigs = [

{

panelId: "test_id_please_dont_remove",

columns: {

  email: { match: "email", type: "email" },

  phone: { match: "phone", type: "phone" },

}

},

{

panelId: "5ba81f07-3d2b-405e-82e7-342c2b6c8731",

columns: {

  email: { match: "email", type: "email" },

  phone: { match: "cell", type: "phone" },

  contact: { match: "contact", type: "phone" }

}

},

{

panelId: "a0bba722-d413-4e79-9de5-3d08cf1b5327",

columns: {

  email: { match: "email", type: "email" },

  phone: { match: "cell", type: "phone" },

  contact: { match: "contact", type: "phone" }

}

},

{

panelId: "78651945-fbb6-4392-83be-20cada52a7dd",

columns: {

  email: { match: "email", type: "email" },

  phone: { match: "cell", type: "phone" },

  contact: { match: "contact", type: "phone" }

}

}

];

// :white_check_mark: CONFIG — inbox tables (alpha-inbox shadow root based)

const inboxConfigs = [

{

selector: "alpha-inbox",

columns: {

  email: { match: "email", type: "email" },

  phone: { match: "phone", type: "phone" },

}

}

];

// :white_check_mark: Create hyperlink element

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}\`;

link.textContent = value;

}

return link;

}

// :white_check_mark: Deep dive — find element by id across all shadow roots

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;

}

// :white_check_mark: Find table inside a panel — searches light DOM and shadow roots

function findTableInPanel(panelEl) {

if (panelEl.shadowRoot) {

const slottedTable = panelEl.querySelector("table");

if (slottedTable) return slottedTable;



const shadowTable = panelEl.shadowRoot.querySelector("table");

if (shadowTable) return shadowTable;



const innerEls = panelEl.shadowRoot.querySelectorAll("\*");

for (const el of innerEls) {

  if (el.shadowRoot) {

    const t = el.shadowRoot.querySelector("table");

    if (t) return t;

  }

}

}

const lightChildren = panelEl.querySelectorAll(“*”);

for (const el of lightChildren) {

if (el.shadowRoot) {

  const t = el.shadowRoot.querySelector("table");

  if (t) return t;

}

}

if (DEBUG) console.warn(“[findTableInPanel] No table found inside panel:”, panelEl?.id);

return null;

}

// :white_check_mark: Find table inside alpha-inbox shadow root

function findTableInInbox(inboxEl) {

if (!inboxEl?.shadowRoot) {

if (DEBUG) console.warn("\[findTableInInbox\] No shadowRoot on inbox element:", inboxEl);

return null;

}

const table = inboxEl.shadowRoot.querySelector(“table”);

if (table) return table;

const shadowChildren = inboxEl.shadowRoot.querySelectorAll(“*”);

for (const el of shadowChildren) {

if (el.shadowRoot) {

  const t = el.shadowRoot.querySelector("table");

  if (t) return t;

}

}

if (DEBUG) console.warn(“[findTableInInbox] No table found inside inbox element:”, inboxEl);

return null;

}

// :white_check_mark: Track active observers so we can disconnect stale ones

const activeInboxObservers = new Map(); // key → { observer, tbody }

function observeTableChanges(key, table, columns) {

const tbody = table.querySelector(“tbody”);

if (!tbody) {

if (DEBUG) console.warn("\[observeTableChanges\] No tbody found in table:", table);

return;

}

// If we already have an observer for this key pointing at the SAME tbody, skip

const existing = activeInboxObservers.get(key);

if (existing && existing.tbody === tbody) return;

// Disconnect any stale observer for this key

if (existing) {

existing.observer.disconnect();

if (DEBUG) console.log("\[observeTableChanges\] Disconnected stale observer for key:", key);

}

let debounceTimer = null;

const tableObserver = new MutationObserver(() => {

if (debounceTimer) return; *// already scheduled, skip*

debounceTimer = setTimeout(() => {

  debounceTimer = null;

  if (DEBUG) console.log("\[observeTableChanges\] Table mutation detected, reapplying hyperlinks.");

  *// Clear stale linkValue cache so changed rows get re-linked*

  tbody.querySelectorAll("td\[data-link-value\]").forEach(td => td.removeAttribute("data-link-value"));

  applyHyperlinksToTable(table, columns);

}, 300);

});

// childList only (no subtree) — we only care about rows being added/removed

tableObserver.observe(tbody, { childList: true });

activeInboxObservers.set(key, { observer: tableObserver, tbody });

if (DEBUG) console.log(“[observeTableChanges] Observer attached for key:”, key);

}

// :white_check_mark: Apply hyperlinks to a single table based on column config

function applyHyperlinksToTable(table, columns) {

const headers = Array.from(table.querySelectorAll(“thead tr th”));

const colIndexMap = {};

headers.forEach((th, index) => {

const text = th.textContent.trim().toLowerCase();

Object.entries(columns).forEach((\[key, config\]) => {

  if (text.includes(config.match)) {

    colIndexMap\[key\] = index;

    if (DEBUG) console.log(\`\[applyHyperlinksToTable\] Matched column "${key}" at index ${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 container = span || td;



  *// Find the framework's actual text node (not inside our injected <a>)*

  *// We look for a direct text node child of the container*

  let textNode = null;

  for (const child of container.childNodes) {

    if (child.nodeType === Node.TEXT_NODE && child.textContent.trim()) {

      textNode = child;

      break;

    }

  }



  *// Read the raw value:*

  *// - If we have a live text node from the framework, that's ground truth*

  *// - If the framework has hidden the text node (some virtualised tables do this),*

  *//   fall back to data-raw-value we stored on first encounter*

  *// - Never read from our injected <a> as that's stale after tab switch*

  let rawValue;

  if (textNode) {

    rawValue = textNode.textContent.trim();

    *// Store it so we can use it if the text node disappears later*

    if (rawValue) td.dataset.rawValue = rawValue;

  } else if (td.dataset.rawValue) {

    rawValue = td.dataset.rawValue;

  } else {

    *// Last resort: read container text, stripping our own link text*

    const existingLink = container.querySelector("a");

    if (existingLink) {

      existingLink.style.display = "none";

      rawValue = container.textContent.trim();

      existingLink.style.display = "";

    } else {

      rawValue = container.textContent.trim();

    }

    if (rawValue) td.dataset.rawValue = rawValue;

  }



  if (!rawValue) return;



  *// Skip if value hasn't changed since we last linked*

  if (td.dataset.linkValue === rawValue) return;



  const link = createLink(columns\[key\].type, rawValue);



  *// NEVER wipe innerHTML — hide the text node and insert/replace only our <a>*

  *// This keeps the framework's DOM nodes intact*

  if (textNode) {

    textNode.textContent = ""; *// blank the text node, keep it in place*

  }



  const existingLink = container.querySelector("a");

  if (existingLink) {

    *// Replace href and text in the existing link — don't re-insert*

    existingLink.href = link.href;

    existingLink.textContent = link.textContent;

  } else {

    container.appendChild(link);

  }



  td.dataset.linkValue = rawValue;

  if (DEBUG) console.log(\`\[applyHyperlinksToTable\] Applied ${columns\[key\].type} link for value: ${rawValue}\`);

});

});

}

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;



*// Handle readonly alpha-text-field → renders 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;

}



*// Handle editable alpha-text-field → renders as input*

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;



makeInputLookLikeLink(input, type, value);

});

}

// :white_check_mark: Apply clickable link inside alpha-key-value shadow root

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;

containerDiv.dataset.linkApplied = “true”;

containerDiv.style.cssText = `

position: relative;

border: 1px solid var(--alpha-primary-inactive-color);

border-radius: 8px;

padding: 0 12px;

background: #fff;

height: 40px;

box-sizing: border-box;

display: flex;

flex-direction: column;

justify-content: flex-end;

width: 100%;

`;

if (keySpan) {

keySpan.style.cssText = \`

  position: absolute;

  top: -9px;

  left: 10px;

  font-size: 14px;

  color: var(--alpha-primary-color);

  font-weight: 400;

  line-height: 1.4;

  background: #fff;

  padding: 0 3px;

  pointer-events: none;

  white-space: nowrap;

\`;

}

valueSpan.style.cssText = `

font-size: 14px;

color: #0066cc;

line-height: 2.8;

margin-top: 4px;

`;

const link = createLink(type, value);

link.style.cssText = `

color: #0066cc;

text-decoration: underline;

cursor: pointer;

font-size: 14px;

`;

valueSpan.innerHTML = “”;

valueSpan.appendChild(link);

if (DEBUG) console.log(`[applyLinkToKeyValue] Applied border-overlap floating label style.`);

}

// :white_check_mark: Detect if we are on the inbox page

function isInboxPage() {

return !!document.querySelector(“alpha-inbox”);

}

// :white_check_mark: Smart table detection for panels + text fields (skipped on inbox page)

let lastTables = new Map();

function smartRunner() {

if (isInboxPage()) return;

const renderer = document.querySelector(“alpha-renderer”);

if (!renderer?.shadowRoot) {

if (DEBUG) console.warn("\[smartRunner\] alpha-renderer or its shadowRoot not found.");

return;

}

panelConfigs.forEach(({ panelId, columns }) => {

const panel = deepFindById(renderer.shadowRoot, panelId);

if (!panel) {

  if (DEBUG) console.warn(\`\[smartRunner\] Panel not found in DOM: ${panelId}\`);

  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);

}

});

textFieldConfigs.forEach(({ panelId, columns }) => {

const panel = deepFindById(renderer.shadowRoot, panelId);

if (!panel) return;

applyLinksToTextFields(panel, columns);

});

}

// :white_check_mark: Smart table detection for inbox tables (skipped on non-inbox pages)

// FIX: track last seen tbody (not just table) to detect tab/filter-driven content swaps

const lastInboxTbodies = new Map();

function inboxRunner() {

if (!isInboxPage()) return;

inboxConfigs.forEach(({ selector, columns }) => {

const inboxEls = document.querySelectorAll(selector);



inboxEls.forEach((inboxEl, idx) => {

  const key = \`${selector}-${idx}\`;

  const table = findTableInInbox(inboxEl);

  if (!table) return;



  const tbody = table.querySelector("tbody");

  const prevTbody = lastInboxTbodies.get(key);



  *// FIX: always reattach observer and reapply when tbody reference changes*

  *// (happens on tab/filter switch when the framework re-renders the table body)*

  if (prevTbody !== tbody) {

    lastInboxTbodies.set(key, tbody);

    *// FIX: clear stale linkValue cache from all cells in the new tbody*

    *// so previously cached values don't suppress link application*

    if (tbody) {

      tbody.querySelectorAll("td\[data-link-value\]").forEach(td => td.removeAttribute("data-link-value"));

    }

    setTimeout(() => {

      applyHyperlinksToTable(table, columns);

      observeTableChanges(key, table, columns);

    }, 300);

  } else {

    *// tbody is the same node but rows may have been updated by the observer*

    applyHyperlinksToTable(table, columns);

  }

});

});

}

// :white_check_mark: Run both runners in a single lightweight loop

setInterval(() => {

smartRunner();

inboxRunner();

}, 1000);