Skip to content

Commit ca8bd74

Browse files
authored
Add tooltips to links to glossary terms (#12654)
This copies over the Glossary Tooltips that have also been added to `dart-lang/site-www` in dart-lang/site-www#6969. Adds two example uses for "widget" [here](https://flutter-docs-prod--pr12654-feat-glossary-tooltip-xhqnklmd.web.app/) and [here](https://flutter-docs-prod--pr12654-feat-glossary-tooltip-xhqnklmd.web.app/app-architecture/concepts#separation-of-concerns).
1 parent bfc4855 commit ca8bd74

File tree

8 files changed

+257
-3
lines changed

8 files changed

+257
-3
lines changed

site/lib/_sass/_site.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
@use 'components/tabs';
3737
@use 'components/theming';
3838
@use 'components/toc';
39+
@use 'components/tooltip';
3940
@use 'components/trailing';
4041

4142
// Styles for specific pages, alphabetically ordered.
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: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ void _setUpSite() {
4444
_setUpExpandableCards();
4545
_setUpPlatformKeys();
4646
_setUpToc();
47+
_setUpTooltips();
4748
}
4849

4950
void _setUpSearchKeybindings() {
@@ -448,3 +449,93 @@ void _setUpTocActiveObserver() {
448449
observer.observe(headings.item(i) as web.Element);
449450
}
450451
}
452+
453+
void _setUpTooltips() {
454+
final tooltipWrappers = web.document.querySelectorAll('.tooltip-wrapper');
455+
456+
final isTouchscreen = web.window.matchMedia('(pointer: coarse)').matches;
457+
458+
void setup({required bool setUpClickListener}) {
459+
for (var i = 0; i < tooltipWrappers.length; i++) {
460+
final linkWrapper = tooltipWrappers.item(i) as web.HTMLElement;
461+
final target = linkWrapper.querySelector('.tooltip-target');
462+
final tooltip = linkWrapper.querySelector('.tooltip') as web.HTMLElement?;
463+
464+
if (target == null || tooltip == null) {
465+
continue;
466+
}
467+
_ensureVisible(tooltip);
468+
469+
if (setUpClickListener && isTouchscreen) {
470+
// On touchscreen devices, toggle tooltip visibility on tap.
471+
target.addEventListener(
472+
'click',
473+
((web.Event e) {
474+
final isVisible = tooltip.classList.contains('visible');
475+
if (!isVisible) {
476+
tooltip.classList.add('visible');
477+
e.preventDefault();
478+
}
479+
}).toJS,
480+
);
481+
}
482+
}
483+
}
484+
485+
void closeAll() {
486+
final visibleTooltips = web.document.querySelectorAll(
487+
'.tooltip.visible',
488+
);
489+
for (var i = 0; i < visibleTooltips.length; i++) {
490+
final tooltip = visibleTooltips.item(i) as web.HTMLElement;
491+
tooltip.classList.remove('visible');
492+
}
493+
}
494+
495+
setup(setUpClickListener: true);
496+
497+
// Reposition tooltips on window resize.
498+
web.EventStreamProviders.resizeEvent.forTarget(web.window).listen((_) {
499+
setup(setUpClickListener: false);
500+
});
501+
502+
// Close tooltips when clicking outside of any tooltip wrapper.
503+
web.EventStreamProviders.clickEvent.forTarget(web.document).listen((e) {
504+
if ((e.target as web.Element).closest('.tooltip-wrapper') == null) {
505+
closeAll();
506+
}
507+
});
508+
509+
// On touchscreen devices, close tooltips when scrolling.
510+
if (isTouchscreen) {
511+
web.EventStreamProviders.scrollEvent.forTarget(web.window).listen((_) {
512+
closeAll();
513+
});
514+
}
515+
}
516+
517+
/// Adjust the tooltip position to ensure it is fully inside the
518+
/// ancestor .content element.
519+
void _ensureVisible(web.HTMLElement tooltip) {
520+
final containerRect = tooltip.closest('.content')?.getBoundingClientRect();
521+
final tooltipRect = tooltip.getBoundingClientRect();
522+
final offset = double.parse(tooltip.getAttribute('data-adjusted') ?? '0');
523+
524+
final tooltipLeft = tooltipRect.left - offset;
525+
final tooltipRight = tooltipRect.right - offset;
526+
final containerLeft = containerRect?.left ?? 0.0;
527+
final containerRight = containerRect?.right ?? web.window.innerWidth;
528+
529+
if (tooltipLeft < containerLeft) {
530+
final offset = containerLeft - tooltipLeft;
531+
tooltip.style.left = 'calc(50% + ${offset}px)';
532+
tooltip.dataset['adjusted'] = offset.toString();
533+
} else if (tooltipRight > containerRight) {
534+
final offset = tooltipRight - containerRight;
535+
tooltip.style.left = 'calc(50% - ${offset}px)';
536+
tooltip.dataset['adjusted'] = (-offset).toString();
537+
} else {
538+
tooltip.style.left = '50%';
539+
tooltip.dataset['adjusted'] = '0';
540+
}
541+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// Copyright 2025 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// 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 = 'cD6c4zCH9whz';
5+
const generatedStylesHash = 'mW0BCRz4bRll';

src/content/app-architecture/concepts.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ Within each layer, you should further separate your application by
3030
feature or functionality. For example, your application's authentication logic
3131
should be in a different class than the search logic.
3232

33-
In Flutter, this applies to widgets in the UI layer as well. You should write
33+
In Flutter, this applies to [widgets](/resources/glossary#widget) in the UI layer as well. You should write
3434
reusable, lean widgets that hold as little logic as possible.
3535

3636
## Layered architecture

src/content/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ Coming from another platform? Check out Flutter for:
4343

4444
[Building layouts][]
4545
: Learn how to create layouts in Flutter,
46-
where everything is a widget.
46+
where everything is a [widget](/resources/glossary#widget).
4747

4848
[Understanding constraints][]
4949
: Once you understand that "Constraints

0 commit comments

Comments
 (0)