Skip to content

Commit 1224648

Browse files
schultekparlough
andauthored
feat: Add tooltips to glossary term links with their definition (#6969)
Shows a tooltip for all glossary links with their term and definition. Glossary links are styled differently than normal links to give a visual indication to the visitor. - On non-touch devices: shows the tooltip on hover. - On touch devices: toggles the tooltip on click. Always positions the tooltip inside the content container with no horizontal overflow. <img width="718" height="500" alt="Screenshot of example glossary term tooltip" src="https://github.com/user-attachments/assets/ff12634d-a094-4569-b71f-daf7487462d6" /> --------- Co-authored-by: Parker Lougheed <[email protected]>
1 parent 3e02945 commit 1224648

File tree

7 files changed

+267
-3
lines changed

7 files changed

+267
-3
lines changed

site/lib/_sass/_site.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
@use 'components/tags';
3939
@use 'components/theming';
4040
@use 'components/toc';
41+
@use 'components/tooltip';
4142
@use 'components/trailing';
4243

4344
@use 'pages/dash';

site/lib/_sass/components/_glossary.scss

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ body.glossary-page main {
2929
}
3030

3131
.expand-button {
32-
&:hover, &:focus-within {
32+
&:hover,
33+
&:focus-within {
3334
transition: transform .25s ease-out;
3435
}
3536
}
@@ -61,7 +62,8 @@ body.glossary-page main {
6162
}
6263
}
6364

64-
.initial-content, .expandable-content {
65+
.initial-content,
66+
.expandable-content {
6567
> :first-child {
6668
margin-top: 0;
6769
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
.tooltip-wrapper {
2+
position: relative;
3+
4+
a.tooltip-target {
5+
color: inherit;
6+
text-decoration: underline;
7+
text-decoration-style: dotted;
8+
}
9+
10+
.tooltip {
11+
visibility: hidden;
12+
13+
display: flex;
14+
position: absolute;
15+
z-index: var(--site-z-floating);
16+
top: 100%;
17+
left: 50%;
18+
transform: translateX(-50%);
19+
20+
flex-flow: column nowrap;
21+
width: 16rem;
22+
23+
background: var(--site-raised-bgColor);
24+
border: 0.05rem solid rgba(0, 0, 0, .125);
25+
border-radius: 0.75rem;
26+
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, .15);
27+
padding: 0.8rem;
28+
29+
font-size: 1rem;
30+
font-weight: normal;
31+
font-style: normal;
32+
33+
.tooltip-header {
34+
font-size: 1.2rem;
35+
font-weight: 500;
36+
margin-bottom: 0.25rem;
37+
}
38+
39+
.tooltip-content {
40+
font-size: 0.875rem;
41+
color: var(--site-secondary-textColor);
42+
}
43+
}
44+
45+
// On non-touch devices, show tooltip on hover or focus.
46+
@media all and not (pointer: coarse) {
47+
&:hover .tooltip {
48+
visibility: visible;
49+
}
50+
51+
&:focus-within .tooltip {
52+
visibility: visible;
53+
}
54+
}
55+
56+
// On touch devices, show tooltip on click (see global_scripts.dart).
57+
@media all and (pointer: coarse) {
58+
.tooltip.visible {
59+
visibility: visible;
60+
}
61+
}
62+
}

site/lib/src/client/global_scripts.dart

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ void _setUpSite() {
4646
_setUpExpandableCards();
4747
_setUpTableOfContents();
4848
_setUpReleaseTags();
49+
_setUpTooltips();
4950
}
5051

5152
void _setUpSidenav() {
@@ -349,6 +350,7 @@ void _setUpExpandableCards() {
349350
currentFragment = currentFragment.substring(1);
350351
}
351352
final expandableCards = web.document.querySelectorAll('.expandable-card');
353+
web.Element? targetCard;
352354

353355
for (var i = 0; i < expandableCards.length; i++) {
354356
final card = expandableCards.item(i) as web.Element;
@@ -372,8 +374,15 @@ void _setUpExpandableCards() {
372374
if (card.id != currentFragment) {
373375
card.classList.add('collapsed');
374376
expandButton.ariaExpanded = 'false';
377+
} else {
378+
targetCard = card;
375379
}
376380
}
381+
382+
if (targetCard != null) {
383+
// Scroll the expanded card into view.
384+
targetCard.scrollIntoView();
385+
}
377386
}
378387

379388
void _setUpTableOfContents() {
@@ -543,3 +552,93 @@ void _setUpReleaseTags() {
543552
fetchVersion('beta');
544553
fetchVersion('dev');
545554
}
555+
556+
void _setUpTooltips() {
557+
final tooltipWrappers = web.document.querySelectorAll('.tooltip-wrapper');
558+
559+
final isTouchscreen = web.window.matchMedia('(pointer: coarse)').matches;
560+
561+
void setup({required bool setUpClickListener}) {
562+
for (var i = 0; i < tooltipWrappers.length; i++) {
563+
final linkWrapper = tooltipWrappers.item(i) as web.HTMLElement;
564+
final target = linkWrapper.querySelector('.tooltip-target');
565+
final tooltip = linkWrapper.querySelector('.tooltip') as web.HTMLElement?;
566+
567+
if (target == null || tooltip == null) {
568+
continue;
569+
}
570+
_ensureVisible(tooltip);
571+
572+
if (setUpClickListener && isTouchscreen) {
573+
// On touchscreen devices, toggle tooltip visibility on tap.
574+
target.addEventListener(
575+
'click',
576+
((web.Event e) {
577+
final isVisible = tooltip.classList.contains('visible');
578+
if (isVisible) {
579+
tooltip.classList.remove('visible');
580+
} else {
581+
tooltip.classList.add('visible');
582+
}
583+
e.preventDefault();
584+
}).toJS,
585+
);
586+
}
587+
}
588+
}
589+
590+
void closeAll() {
591+
final visibleTooltips = web.document.querySelectorAll(
592+
'.tooltip.visible',
593+
);
594+
for (var i = 0; i < visibleTooltips.length; i++) {
595+
final tooltip = visibleTooltips.item(i) as web.HTMLElement;
596+
tooltip.classList.remove('visible');
597+
}
598+
}
599+
600+
setup(setUpClickListener: true);
601+
602+
// Reposition tooltips on window resize.
603+
web.EventStreamProviders.resizeEvent.forTarget(web.window).listen((_) {
604+
setup(setUpClickListener: false);
605+
});
606+
607+
// Close tooltips when clicking outside of any tooltip wrapper.
608+
web.EventStreamProviders.clickEvent.forTarget(web.document).listen((e) {
609+
if ((e.target as web.Element).closest('.tooltip-wrapper') == null) {
610+
closeAll();
611+
}
612+
});
613+
614+
// On touchscreen devices, close tooltips when scrolling.
615+
if (isTouchscreen) {
616+
web.EventStreamProviders.scrollEvent.forTarget(web.window).listen((_) {
617+
closeAll();
618+
});
619+
}
620+
}
621+
622+
/// Adjust the tooltip position to ensure it is fully inside the
623+
/// ancestor .content element.
624+
void _ensureVisible(web.HTMLElement tooltip) {
625+
final containerRect = tooltip.closest('.content')!.getBoundingClientRect();
626+
final tooltipRect = tooltip.getBoundingClientRect();
627+
final offset = double.parse(tooltip.getAttribute('data-adjusted') ?? '0');
628+
629+
final tooltipLeft = tooltipRect.left - offset;
630+
final tooltipRight = tooltipRect.right - offset;
631+
632+
if (tooltipLeft < containerRect.left) {
633+
final offset = containerRect.left - tooltipLeft;
634+
tooltip.style.left = 'calc(50% + ${offset}px)';
635+
tooltip.dataset['adjusted'] = offset.toString();
636+
} else if (tooltipRight > containerRect.right) {
637+
final offset = tooltipRight - containerRect.right;
638+
tooltip.style.left = 'calc(50% - ${offset}px)';
639+
tooltip.dataset['adjusted'] = (-offset).toString();
640+
} else {
641+
tooltip.style.left = '50%';
642+
tooltip.dataset['adjusted'] = '0';
643+
}
644+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
import 'package:jaspr/jaspr.dart';
6+
import 'package:jaspr_content/jaspr_content.dart';
7+
8+
import '../pages/glossary.dart';
9+
import '../util.dart';
10+
11+
/// A node-processing, page extension for Jaspr Content that looks for links to
12+
/// glossary entries and enhances them with interactive glossary tooltips.
13+
class GlossaryLinkProcessor implements PageExtension {
14+
const GlossaryLinkProcessor();
15+
16+
@override
17+
Future<List<Node>> apply(Page page, List<Node> nodes) async {
18+
final glossary = Glossary.fromList(page.data['glossary'] as List<Object?>);
19+
return _processNodes(nodes, glossary);
20+
}
21+
22+
List<Node> _processNodes(List<Node> nodes, Glossary glossary) {
23+
final processedNodes = <Node>[];
24+
25+
for (final node in nodes) {
26+
if (node is ElementNode &&
27+
node.tag == 'a' &&
28+
node.attributes['href']?.startsWith('/resources/glossary') == true) {
29+
// Found a glossary link, extract its id from the url and
30+
// create the tooltip component.
31+
32+
final id = Uri.parse(node.attributes['href']!).fragment;
33+
final entry = glossary.entries.where((e) => e.id == id).firstOrNull;
34+
35+
if (entry == null) {
36+
// If the glossary entry is not found, keep the original node.
37+
processedNodes.add(node);
38+
continue;
39+
}
40+
41+
processedNodes.add(
42+
ElementNode(
43+
'span',
44+
{'class': 'tooltip-wrapper'},
45+
[
46+
ElementNode('a', {
47+
...node.attributes,
48+
'class': [
49+
?node.attributes['class'],
50+
'tooltip-target',
51+
].toClasses,
52+
}, node.children),
53+
ComponentNode(GlossaryTooltip(entry: entry)),
54+
],
55+
),
56+
);
57+
} else if (node is ElementNode && node.children != null) {
58+
processedNodes.add(
59+
ElementNode(
60+
node.tag,
61+
node.attributes,
62+
_processNodes(node.children!, glossary),
63+
),
64+
);
65+
} else {
66+
processedNodes.add(node);
67+
}
68+
}
69+
70+
return processedNodes;
71+
}
72+
}
73+
74+
class GlossaryTooltip extends StatelessComponent {
75+
const GlossaryTooltip({required this.entry});
76+
77+
final GlossaryEntry entry;
78+
79+
@override
80+
Component build(BuildContext context) {
81+
return span(classes: 'tooltip', [
82+
span(classes: 'tooltip-header', [text(entry.term)]),
83+
span(classes: 'tooltip-content', [
84+
text(entry.shortDescription),
85+
text(' '),
86+
a(
87+
href: '/resources/glossary#${entry.id}',
88+
attributes: {
89+
'title':
90+
'Learn more about \'${entry.term}\' and '
91+
'find related resources.',
92+
},
93+
[text('Learn more')],
94+
),
95+
]),
96+
]);
97+
}
98+
}

site/lib/src/extensions/registry.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import 'package:jaspr_content/jaspr_content.dart';
66

77
import 'attribute_processor.dart';
88
import 'code_block_processor.dart';
9+
import 'glossary_link_processor.dart';
910
import 'header_extractor.dart';
1011
import 'header_processor.dart';
1112
import 'table_processor.dart';
@@ -18,4 +19,5 @@ const List<PageExtension> allNodeProcessingExtensions = [
1819
HeaderWrapperExtension(),
1920
TableWrapperExtension(),
2021
CodeBlockProcessor(),
22+
GlossaryLinkProcessor(),
2123
];

site/lib/src/style_hash.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
// dart format off
33

44
/// The generated hash of the `main.css` file.
5-
const generatedStylesHash = 'YoU2normseCd';
5+
const generatedStylesHash = 'EcoGkjHB12Fs';

0 commit comments

Comments
 (0)