diff --git a/docs/api/types.md b/docs/api/types.md index e3e7f41..6ca4ba6 100644 --- a/docs/api/types.md +++ b/docs/api/types.md @@ -229,6 +229,9 @@ interface FlowEdge> { /** Center label text. */ label?: string; + /** Render label, labelStart and labelEnd as HTML rather than text. Default: false */ + labelHtml?: boolean; + /** Center label position along path (0 = source, 1 = target). Default: 0.5 */ labelPosition?: number; diff --git a/docs/configuration/edges.md b/docs/configuration/edges.md index 2d2ae39..b9df438 100644 --- a/docs/configuration/edges.md +++ b/docs/configuration/edges.md @@ -67,6 +67,7 @@ These are covered in the [v0.2.1-alpha migration guide](../migration/v0.2.1-alph label: 'connects to', // Optional. Center label text. labelStart: 'from', // Optional. Label near source. labelEnd: 'to', // Optional. Label near target. + labelHtml: false, // Optional. Render the labels as HTML rather than text. color: '#ff0000', // Optional. Stroke color string or gradient object. strokeWidth: 2, // Optional. Stroke width. animated: true, // Optional. true/'dash', 'pulse', or 'dot'. diff --git a/docs/edges/labels.md b/docs/edges/labels.md index 648b871..0938fe8 100644 --- a/docs/edges/labels.md +++ b/docs/edges/labels.md @@ -90,6 +90,41 @@ Hover each edge to see the label appear — the top edge is always visible, the ``` ::enddemo +## HTML labels + +Label text is written with `textContent`, so markup in a label shows as the tags themselves. Set `labelHtml: true` on the edge to render its labels as HTML instead — for a label that needs a line break, an icon, or a piece of emphasis: + +::demo +```html +
+
+ +
+
+``` +::enddemo + +The flag covers all three positions — `label`, `labelStart` and `labelEnd` — and the value goes to `innerHTML` unchanged. Like any HTML you hand a framework, it is trusted: pass anything a user typed through your own escaping or sanitiser first. + ## Example ```js diff --git a/src/core/types.ts b/src/core/types.ts index 081187c..3bfdb3e 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -360,6 +360,18 @@ export interface FlowEdge> { /** Optional label displayed on the edge */ label?: string; + /** + * Render the labels as HTML instead of text. + * + * Labels are written with `textContent` by default, so markup in them shows as the tags + * themselves. Set this to render `label`, `labelStart` and `labelEnd` as HTML — for a label that + * needs a line break, an icon or a piece of emphasis. + * + * The value goes to `innerHTML` unchanged. Like any HTML you hand a framework, it is trusted: + * pass user input through your own escaping or sanitiser first. + */ + labelHtml?: boolean; + /** Position of the center label along the path (0 = source, 1 = target). Default: 0.5 */ labelPosition?: number; diff --git a/src/plugin/directives/flow-edge.test.ts b/src/plugin/directives/flow-edge.test.ts index 543a090..a2f297d 100644 --- a/src/plugin/directives/flow-edge.test.ts +++ b/src/plugin/directives/flow-edge.test.ts @@ -655,3 +655,56 @@ describe('x-flow-edge label path-length caching (Task B1)', () => { expect(getTotalLengthSpy).toHaveBeenCalled(); // re-measured on new d }); }); + +describe('x-flow-edge label markup (labelHtml)', () => { + // The harness builds host > .flow-container > svg > g and no viewport, so labels are created + // but never appended. Marking the container as the viewport gives `ensureLabel` somewhere to + // put them, and the label block re-runs on the next mutation. + const withViewport = (host: HTMLElement): void => { + host.querySelector('.flow-container')!.classList.add('flow-viewport'); + }; + + it('writes a label as text by default, so markup shows as the tags it is', async () => { + const { host, data } = mountEdges(flatNodes(), [{ id: 'e1', source: 'a', target: 'b' }]); + await flush(); + withViewport(host); + + data.getEdge('e1')!.label = 'bold'; + await flush(); + + const label = host.querySelector('.flow-edge-label') as HTMLElement; + expect(label.textContent).toBe('bold'); + expect(label.querySelector('b')).toBeNull(); + }); + + it('renders a label as HTML when the edge asks for it', async () => { + const { host, data } = mountEdges(flatNodes(), [ + { id: 'e1', source: 'a', target: 'b', labelHtml: true }, + ]); + await flush(); + withViewport(host); + + data.getEdge('e1')!.label = 'over the limit
and no manager'; + await flush(); + + const label = host.querySelector('.flow-edge-label') as HTMLElement; + expect(label.querySelector('br')).not.toBeNull(); + expect(label.textContent).toBe('over the limitand no manager'); + }); + + it('applies to the start and end labels too', async () => { + const { host, data } = mountEdges(flatNodes(), [ + { id: 'e1', source: 'a', target: 'b', labelHtml: true }, + ]); + await flush(); + withViewport(host); + + const edge = data.getEdge('e1')!; + edge.labelStart = 'from'; + edge.labelEnd = 'to'; + await flush(); + + expect(host.querySelector('.flow-edge-label-start')!.querySelector('em')).not.toBeNull(); + expect(host.querySelector('.flow-edge-label-end')!.querySelector('em')).not.toBeNull(); + }); +}); diff --git a/src/plugin/directives/flow-edge.ts b/src/plugin/directives/flow-edge.ts index 2d98ff7..052cc2a 100644 --- a/src/plugin/directives/flow-edge.ts +++ b/src/plugin/directives/flow-edge.ts @@ -1754,6 +1754,7 @@ export function registerFlowEdgeDirective(Alpine: Alpine) { cssClass: string, viewport: Element | null, edgeId: string, + asHtml: boolean, ): HTMLDivElement | null => { if (text) { // If closure lost reference (e.g., directive re-init), reclaim from DOM @@ -1772,7 +1773,13 @@ export function registerFlowEdgeDirective(Alpine: Alpine) { existing.dataset.flowEdgeId = edgeId; if (viewport) viewport.appendChild(existing); } - existing.textContent = text; + // `innerHTML` only when the edge asked for it. The value is trusted the way any + // framework trusts HTML it is handed — see `labelHtml` in the Edge type. + if (asHtml) { + if (existing.innerHTML !== text) existing.innerHTML = text; + } else if (existing.textContent !== text) { + existing.textContent = text; + } return existing; } if (existing) { existing.remove(); } @@ -1781,6 +1788,7 @@ export function registerFlowEdgeDirective(Alpine: Alpine) { const viewport = el.closest('.flow-viewport'); const labelVis = edge.labelVisibility ?? 'always'; + const labelHtml = edge.labelHtml === true; // Lazily measure + cache the path length, keyed by the `d` attribute set // above. Only called when a label actually needs it, so edges without @@ -1795,7 +1803,7 @@ export function registerFlowEdgeDirective(Alpine: Alpine) { }; // Center label (uses labelPosition percentage or default midpoint) - labelEl = ensureLabel(labelEl, edge.label, 'flow-edge-label', viewport, edge.id); + labelEl = ensureLabel(labelEl, edge.label, 'flow-edge-label', viewport, edge.id, labelHtml); if (labelEl) { const totalLength = getCachedTotalLength(); if (totalLength > 0) { @@ -1810,7 +1818,7 @@ export function registerFlowEdgeDirective(Alpine: Alpine) { } // Start label (fixed pixel offset from source end) - labelStartEl = ensureLabel(labelStartEl, edge.labelStart, 'flow-edge-label flow-edge-label-start', viewport, edge.id); + labelStartEl = ensureLabel(labelStartEl, edge.labelStart, 'flow-edge-label flow-edge-label-start', viewport, edge.id, labelHtml); if (labelStartEl) { const totalLength = getCachedTotalLength(); if (totalLength > 0) { @@ -1822,7 +1830,7 @@ export function registerFlowEdgeDirective(Alpine: Alpine) { } // End label (fixed pixel offset from target end) - labelEndEl = ensureLabel(labelEndEl, edge.labelEnd, 'flow-edge-label flow-edge-label-end', viewport, edge.id); + labelEndEl = ensureLabel(labelEndEl, edge.labelEnd, 'flow-edge-label flow-edge-label-end', viewport, edge.id, labelHtml); if (labelEndEl) { const totalLength = getCachedTotalLength(); if (totalLength > 0) {