Skip to content

Commit 594b3eb

Browse files
committed
feat: add glossary tooltip
1 parent aa11370 commit 594b3eb

File tree

7 files changed

+248
-4
lines changed

7 files changed

+248
-4
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: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ body.glossary-page main {
1313
min-height: 8rem;
1414

1515
.initial-content {
16+
1617
// Only show the first paragraph if collapsed.
1718
> :not(:first-child) {
1819
display: none;
@@ -29,7 +30,9 @@ body.glossary-page main {
2930
}
3031

3132
.expand-button {
32-
&:hover, &:focus-within {
33+
34+
&:hover,
35+
&:focus-within {
3336
transition: transform .25s ease-out;
3437
}
3538
}
@@ -61,7 +64,8 @@ body.glossary-page main {
6164
}
6265
}
6366

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

site/lib/src/client/global_scripts.dart

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

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 = 'wjK0Mdh356gO';
5+
const generatedStylesHash = 'sOkwtE81eV18';

0 commit comments

Comments
 (0)