Skip to content

Commit 322c15b

Browse files
authored
feat: add markdown support to tooltip (#10245)
1 parent a58062f commit 322c15b

File tree

14 files changed

+302
-8
lines changed

14 files changed

+302
-8
lines changed

dev/playground/tooltip.html

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+
<title>Tooltip</title>
8+
<script type="module" src="../common.js"></script>
9+
<script type="module">
10+
import '@vaadin/tooltip';
11+
</script>
12+
</head>
13+
<body>
14+
<section class="section">
15+
<h2 class="heading">Tooltip with Markdown</h2>
16+
<button id="tooltip-with-markdown-target">Target</button>
17+
<vaadin-tooltip markdown for="tooltip-with-markdown-target"></vaadin-tooltip>
18+
</section>
19+
</body>
20+
21+
<script type="module">
22+
const markdownTooltip = document.querySelector('vaadin-tooltip[for="tooltip-with-markdown-target"]');
23+
markdownTooltip.text = `
24+
## Tooltip Title
25+
26+
This tooltip contains:
27+
28+
- **Bold** and *italic* text
29+
- A [link](https://vaadin.com)
30+
- Code: \`console.log('Hello')\``;
31+
</script>
32+
</html>

packages/tooltip/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"@open-wc/dedupe-mixin": "^1.3.0",
3737
"@vaadin/a11y-base": "25.0.0-alpha20",
3838
"@vaadin/component-base": "25.0.0-alpha20",
39+
"@vaadin/markdown": "25.0.0-alpha20",
3940
"@vaadin/overlay": "25.0.0-alpha20",
4041
"@vaadin/popover": "25.0.0-alpha20",
4142
"@vaadin/vaadin-themable-mixin": "25.0.0-alpha20",

packages/tooltip/src/styles/vaadin-tooltip-overlay-base-styles.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export const tooltipOverlayStyles = css`
2828
var(--vaadin-tooltip-shadow, 0 3px 8px -1px rgba(0, 0, 0, 0.2));
2929
}
3030
31-
[part='content'] {
31+
:host(:not([markdown])) [part='content'] {
3232
white-space: pre-wrap;
3333
}
3434

packages/tooltip/src/vaadin-tooltip-mixin.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,4 +87,13 @@ export declare class TooltipMixinClass {
8787
* String used as a tooltip content.
8888
*/
8989
text: string | null | undefined;
90+
91+
/**
92+
* When enabled, the tooltip text is rendered as Markdown.
93+
*
94+
* **Note:** Using Markdown is discouraged if accessibility of the tooltip
95+
* content is essential, as semantics of the rendered HTML content
96+
* (headers, lists, ...) will not be conveyed to assistive technologies.
97+
*/
98+
markdown: boolean;
9099
}

packages/tooltip/src/vaadin-tooltip-mixin.js

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,19 @@ export const TooltipMixin = (superClass) =>
328328
type: String,
329329
},
330330

331+
/**
332+
* When enabled, the tooltip text is rendered as Markdown.
333+
*
334+
* **Note:** Using Markdown is discouraged if accessibility of the tooltip
335+
* content is essential, as semantics of the rendered HTML content
336+
* (headers, lists, ...) will not be conveyed to assistive technologies.
337+
*/
338+
markdown: {
339+
type: Boolean,
340+
value: false,
341+
reflectToAttribute: true,
342+
},
343+
331344
/**
332345
* Element used to link with the `aria-describedby`
333346
* attribute. When not set, defaults to `target`.
@@ -447,9 +460,8 @@ export const TooltipMixin = (superClass) =>
447460
updated(props) {
448461
super.updated(props);
449462

450-
if (props.has('text') || props.has('generator') || props.has('context')) {
463+
if (props.has('text') || props.has('generator') || props.has('context') || props.has('markdown')) {
451464
this.__updateContent();
452-
this.$.overlay.toggleAttribute('hidden', this.__contentNode.textContent.trim() === '');
453465
}
454466
}
455467

@@ -681,8 +693,17 @@ export const TooltipMixin = (superClass) =>
681693
}
682694

683695
/** @private */
684-
__updateContent() {
685-
this.__contentNode.textContent = typeof this.generator === 'function' ? this.generator(this.context) : this.text;
696+
async __updateContent() {
697+
const content = typeof this.generator === 'function' ? this.generator(this.context) : this.text;
698+
699+
if (this.markdown && content) {
700+
const helpers = await this.constructor.__importMarkdownHelpers();
701+
helpers.renderMarkdownToElement(this.__contentNode, content);
702+
} else {
703+
this.__contentNode.textContent = content || '';
704+
}
705+
706+
this.$.overlay.toggleAttribute('hidden', this.__contentNode.textContent.trim() === '');
686707
this.dispatchEvent(new CustomEvent('content-changed', { detail: { content: this.__contentNode.textContent } }));
687708
}
688709

@@ -708,6 +729,14 @@ export const TooltipMixin = (superClass) =>
708729
}
709730
}
710731

732+
/** @private **/
733+
static __importMarkdownHelpers() {
734+
if (!this.__markdownHelpers) {
735+
this.__markdownHelpers = import('@vaadin/markdown/src/markdown-helpers.js');
736+
}
737+
return this.__markdownHelpers;
738+
}
739+
711740
/**
712741
* Fired when the tooltip text content is changed.
713742
*

packages/tooltip/src/vaadin-tooltip.d.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,19 @@ export interface TooltipEventMap extends HTMLElementEventMap, TooltipCustomEvent
2828
* <vaadin-tooltip text="Click to save changes" for="confirm"></vaadin-tooltip>
2929
* ```
3030
*
31+
* ### Markdown Support
32+
*
33+
* The tooltip supports rendering Markdown content by setting the `markdown` property:
34+
*
35+
* ```html
36+
* <button id="info">Info</button>
37+
* <vaadin-tooltip
38+
* text="**Important:** Click to view *detailed* information"
39+
* markdown
40+
* for="info">
41+
* </vaadin-tooltip>
42+
* ```
43+
*
3144
* ### Styling
3245
*
3346
* The following shadow DOM parts are available for styling:
@@ -41,6 +54,7 @@ export interface TooltipEventMap extends HTMLElementEventMap, TooltipCustomEvent
4154
*
4255
* Attribute | Description
4356
* -----------------|----------------------------------------
57+
* `markdown` | Reflects the `markdown` property value.
4458
* `position` | Reflects the `position` property value.
4559
*
4660
* ### Custom CSS Properties

packages/tooltip/src/vaadin-tooltip.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,19 @@ import { TooltipMixin } from './vaadin-tooltip-mixin.js';
2020
* <vaadin-tooltip text="Click to save changes" for="confirm"></vaadin-tooltip>
2121
* ```
2222
*
23+
* ### Markdown Support
24+
*
25+
* The tooltip supports rendering Markdown content by setting the `markdown` property:
26+
*
27+
* ```html
28+
* <button id="info">Info</button>
29+
* <vaadin-tooltip
30+
* text="**Important:** Click to view *detailed* information"
31+
* markdown
32+
* for="info">
33+
* </vaadin-tooltip>
34+
* ```
35+
*
2336
* ### Styling
2437
*
2538
* The following shadow DOM parts are available for styling:
@@ -33,6 +46,7 @@ import { TooltipMixin } from './vaadin-tooltip-mixin.js';
3346
*
3447
* Attribute | Description
3548
* -----------------|----------------------------------------
49+
* `markdown` | Reflects the `markdown` property value.
3650
* `position` | Reflects the `position` property value.
3751
*
3852
* ### Custom CSS Properties
@@ -90,6 +104,7 @@ class Tooltip extends TooltipMixin(ThemePropertyMixin(ElementMixin(PolylitMixin(
90104
@mouseenter="${this.__onOverlayMouseEnter}"
91105
@mouseleave="${this.__onOverlayMouseLeave}"
92106
modeless
107+
?markdown="${this.markdown}"
93108
exportparts="overlay, content"
94109
><slot name="overlay"></slot
95110
></vaadin-tooltip-overlay>
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { expect } from '@vaadin/chai-plugins';
2+
import { fixtureSync, nextRender, nextUpdate } from '@vaadin/testing-helpers';
3+
import sinon from 'sinon';
4+
import { Tooltip } from '../src/vaadin-tooltip.js';
5+
6+
describe('markdown', () => {
7+
let tooltip, overlay, contentNode;
8+
9+
before(async () => {
10+
// Preload markdown helpers to avoid dynamic import delays
11+
await Tooltip.__importMarkdownHelpers();
12+
});
13+
14+
beforeEach(async () => {
15+
tooltip = fixtureSync('<vaadin-tooltip></vaadin-tooltip>');
16+
await nextRender();
17+
overlay = tooltip.shadowRoot.querySelector('vaadin-tooltip-overlay');
18+
contentNode = tooltip.querySelector('[slot="overlay"]');
19+
});
20+
21+
it('should have markdown property with default value false', () => {
22+
expect(tooltip.markdown).to.equal(false);
23+
});
24+
25+
it('should reflect markdown property to attribute', async () => {
26+
expect(tooltip.hasAttribute('markdown')).to.be.false;
27+
28+
tooltip.markdown = true;
29+
await nextUpdate(tooltip);
30+
expect(tooltip.hasAttribute('markdown')).to.be.true;
31+
});
32+
33+
describe('markdown disabled', () => {
34+
it('should not parse markdown syntax by default', async () => {
35+
tooltip.text = '**Bold text** and *italic text*';
36+
await nextUpdate(tooltip);
37+
expect(contentNode.innerHTML).to.equal('**Bold text** and *italic text*');
38+
});
39+
});
40+
41+
describe('markdown enabled', () => {
42+
beforeEach(async () => {
43+
tooltip.markdown = true;
44+
await nextUpdate(tooltip);
45+
});
46+
47+
it('should parse markdown syntax', async () => {
48+
tooltip.text = '**Bold text** and *italic text*';
49+
await nextUpdate(tooltip);
50+
expect(contentNode.innerHTML.trim()).to.equal('<p><strong>Bold text</strong> and <em>italic text</em></p>');
51+
});
52+
53+
it('should update content when markdown text changes', async () => {
54+
tooltip.text = '# Heading 1';
55+
await nextUpdate(tooltip);
56+
expect(contentNode.innerHTML.trim()).to.equal('<h1>Heading 1</h1>');
57+
58+
tooltip.text = '## Heading 2';
59+
await nextUpdate(tooltip);
60+
expect(contentNode.innerHTML.trim()).to.equal('<h2>Heading 2</h2>');
61+
});
62+
63+
it('should handle empty/null/undefined content', async () => {
64+
tooltip.text = '';
65+
await nextUpdate(tooltip);
66+
expect(contentNode.innerHTML).to.equal('');
67+
68+
tooltip.text = null;
69+
await nextUpdate(tooltip);
70+
expect(contentNode.innerHTML).to.equal('');
71+
72+
tooltip.text = undefined;
73+
await nextUpdate(tooltip);
74+
expect(contentNode.innerHTML).to.equal('');
75+
});
76+
77+
it('should sanitize markdown', async () => {
78+
tooltip.text = '<script>alert("xss")</script>\n\n**Safe content**';
79+
await nextUpdate(tooltip);
80+
81+
expect(contentNode.innerHTML.trim()).to.equal('<p><strong>Safe content</strong></p>');
82+
});
83+
84+
it('should hide overlay when markdown content is empty', async () => {
85+
tooltip.text = '';
86+
await nextUpdate(tooltip);
87+
88+
expect(overlay.hasAttribute('hidden')).to.be.true;
89+
});
90+
91+
it('should show overlay when markdown content is not empty', async () => {
92+
tooltip.text = '**Content**';
93+
await nextUpdate(tooltip);
94+
95+
expect(overlay.hasAttribute('hidden')).to.be.false;
96+
});
97+
98+
it('should fire content-changed event when markdown content is set', async () => {
99+
const spy = sinon.spy();
100+
tooltip.addEventListener('content-changed', spy);
101+
102+
tooltip.text = '**Bold text**';
103+
await nextUpdate(tooltip);
104+
105+
expect(spy.callCount).to.equal(1);
106+
expect(spy.firstCall.args[0].detail).to.deep.equal({ content: 'Bold text\n' });
107+
});
108+
});
109+
110+
describe('switching between text and markdown', () => {
111+
it('should switch from text to markdown', async () => {
112+
tooltip.text = '**Bold text**';
113+
await nextUpdate(tooltip);
114+
expect(contentNode.innerHTML).to.equal('**Bold text**');
115+
116+
tooltip.markdown = true;
117+
await nextUpdate(tooltip);
118+
expect(contentNode.innerHTML.trim()).to.equal('<p><strong>Bold text</strong></p>');
119+
});
120+
121+
it('should switch from markdown to text', async () => {
122+
tooltip.markdown = true;
123+
tooltip.text = '**Bold text**';
124+
await nextUpdate(tooltip);
125+
expect(contentNode.innerHTML.trim()).to.equal('<p><strong>Bold text</strong></p>');
126+
127+
tooltip.markdown = false;
128+
await nextUpdate(tooltip);
129+
expect(contentNode.innerHTML).to.equal('**Bold text**');
130+
});
131+
132+
it('should fire content-changed event when switching content types', async () => {
133+
const spy = sinon.spy();
134+
tooltip.addEventListener('content-changed', spy);
135+
136+
tooltip.text = '**Bold text**';
137+
await nextUpdate(tooltip);
138+
139+
expect(spy.callCount).to.equal(1);
140+
expect(spy.firstCall.args[0].detail).to.deep.equal({ content: '**Bold text**' });
141+
142+
tooltip.markdown = true;
143+
await nextUpdate(tooltip);
144+
145+
expect(spy.callCount).to.equal(2);
146+
expect(spy.secondCall.args[0].detail).to.deep.equal({ content: 'Bold text\n' });
147+
});
148+
});
149+
});

packages/tooltip/test/typings/tooltip.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ assertType<string | null | undefined>(tooltip.text);
1919
assertType<Record<string, unknown>>(tooltip.context);
2020
assertType<(context: Record<string, unknown>) => string>(tooltip.generator);
2121
assertType<boolean>(tooltip.manual);
22+
assertType<boolean>(tooltip.markdown);
2223
assertType<boolean>(tooltip.opened);
2324
assertType<number>(tooltip.focusDelay);
2425
assertType<number>(tooltip.hideDelay);
8.6 KB
Loading

0 commit comments

Comments
 (0)