Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions site/lib/_sass/_site.scss
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
@use 'components/tabs';
@use 'components/theming';
@use 'components/toc';
@use 'components/tooltip';
@use 'components/trailing';

// Styles for specific pages, alphabetically ordered.
Expand Down
62 changes: 62 additions & 0 deletions site/lib/_sass/components/_tooltip.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
.tooltip-wrapper {
position: relative;

a.tooltip-target {
color: inherit;
text-decoration: underline;
text-decoration-style: dotted;
}

.tooltip {
visibility: hidden;

display: flex;
position: absolute;
z-index: var(--site-z-floating);
top: 100%;
left: 50%;
transform: translateX(-50%);

flex-flow: column nowrap;
width: 16rem;

background: var(--site-raised-bgColor);
border: 0.05rem solid rgba(0, 0, 0, .125);
border-radius: 0.75rem;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, .15);
padding: 0.8rem;

font-size: 1rem;
font-weight: normal;
font-style: normal;

.tooltip-header {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 0.25rem;
}

.tooltip-content {
font-size: 0.875rem;
color: var(--site-secondary-textColor);
}
}

// On non-touch devices, show tooltip on hover or focus.
@media all and not (pointer: coarse) {
&:hover .tooltip {
visibility: visible;
}

&:focus-within .tooltip {
visibility: visible;
}
}

// On touch devices, show tooltip on click (see global_scripts.dart).
@media all and (pointer: coarse) {
.tooltip.visible {
visibility: visible;
}
}
}
91 changes: 91 additions & 0 deletions site/lib/src/client/global_scripts.dart
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ void _setUpSite() {
_setUpExpandableCards();
_setUpPlatformKeys();
_setUpToc();
_setUpTooltips();
}

void _setUpSearchKeybindings() {
Expand Down Expand Up @@ -448,3 +449,93 @@ void _setUpTocActiveObserver() {
observer.observe(headings.item(i) as web.Element);
}
}

void _setUpTooltips() {
final tooltipWrappers = web.document.querySelectorAll('.tooltip-wrapper');

final isTouchscreen = web.window.matchMedia('(pointer: coarse)').matches;

void setup({required bool setUpClickListener}) {
for (var i = 0; i < tooltipWrappers.length; i++) {
final linkWrapper = tooltipWrappers.item(i) as web.HTMLElement;
final target = linkWrapper.querySelector('.tooltip-target');
final tooltip = linkWrapper.querySelector('.tooltip') as web.HTMLElement?;

if (target == null || tooltip == null) {
continue;
}
_ensureVisible(tooltip);

if (setUpClickListener && isTouchscreen) {
// On touchscreen devices, toggle tooltip visibility on tap.
target.addEventListener(
'click',
((web.Event e) {
final isVisible = tooltip.classList.contains('visible');
if (!isVisible) {
tooltip.classList.add('visible');
e.preventDefault();
}
}).toJS,
);
}
}
}

void closeAll() {
final visibleTooltips = web.document.querySelectorAll(
'.tooltip.visible',
);
for (var i = 0; i < visibleTooltips.length; i++) {
final tooltip = visibleTooltips.item(i) as web.HTMLElement;
tooltip.classList.remove('visible');
}
}

setup(setUpClickListener: true);

// Reposition tooltips on window resize.
web.EventStreamProviders.resizeEvent.forTarget(web.window).listen((_) {
setup(setUpClickListener: false);
});

// Close tooltips when clicking outside of any tooltip wrapper.
web.EventStreamProviders.clickEvent.forTarget(web.document).listen((e) {
if ((e.target as web.Element).closest('.tooltip-wrapper') == null) {
closeAll();
}
});

// On touchscreen devices, close tooltips when scrolling.
if (isTouchscreen) {
web.EventStreamProviders.scrollEvent.forTarget(web.window).listen((_) {
closeAll();
});
}
}

/// Adjust the tooltip position to ensure it is fully inside the
/// ancestor .content element.
void _ensureVisible(web.HTMLElement tooltip) {
final containerRect = tooltip.closest('.content')?.getBoundingClientRect();
final tooltipRect = tooltip.getBoundingClientRect();
final offset = double.parse(tooltip.getAttribute('data-adjusted') ?? '0');

final tooltipLeft = tooltipRect.left - offset;
final tooltipRight = tooltipRect.right - offset;
final containerLeft = containerRect?.left ?? 0.0;
final containerRight = containerRect?.right ?? web.window.innerWidth;

if (tooltipLeft < containerLeft) {
final offset = containerLeft - tooltipLeft;
tooltip.style.left = 'calc(50% + ${offset}px)';
tooltip.dataset['adjusted'] = offset.toString();
} else if (tooltipRight > containerRight) {
final offset = tooltipRight - containerRight;
tooltip.style.left = 'calc(50% - ${offset}px)';
tooltip.dataset['adjusted'] = (-offset).toString();
} else {
tooltip.style.left = '50%';
tooltip.dataset['adjusted'] = '0';
}
}
98 changes: 98 additions & 0 deletions site/lib/src/extensions/glossary_link_processor.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Copyright 2025 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'package:jaspr/jaspr.dart';
import 'package:jaspr_content/jaspr_content.dart';

import '../pages/glossary.dart';
import '../util.dart';

/// A node-processing, page extension for Jaspr Content that looks for links to
/// glossary entries and enhances them with interactive glossary tooltips.
class GlossaryLinkProcessor implements PageExtension {
const GlossaryLinkProcessor();

@override
Future<List<Node>> apply(Page page, List<Node> nodes) async {
final glossary = Glossary.fromList(page.data['glossary'] as List<Object?>);
return _processNodes(nodes, glossary);
}

List<Node> _processNodes(List<Node> nodes, Glossary glossary) {
final processedNodes = <Node>[];

for (final node in nodes) {
if (node is ElementNode &&
node.tag == 'a' &&
node.attributes['href']?.startsWith('/resources/glossary') == true) {
// Found a glossary link, extract its id from the url and
// create the tooltip component.

final id = Uri.parse(node.attributes['href']!).fragment;
final entry = glossary.entries.where((e) => e.id == id).firstOrNull;

if (entry == null) {
// If the glossary entry is not found, keep the original node.
processedNodes.add(node);
continue;
}

processedNodes.add(
ElementNode(
'span',
{'class': 'tooltip-wrapper'},
[
ElementNode('a', {
...node.attributes,
'class': [
?node.attributes['class'],
'tooltip-target',
].toClasses,
}, node.children),
ComponentNode(GlossaryTooltip(entry: entry)),
],
),
);
} else if (node is ElementNode && node.children != null) {
processedNodes.add(
ElementNode(
node.tag,
node.attributes,
_processNodes(node.children!, glossary),
),
);
} else {
processedNodes.add(node);
}
}

return processedNodes;
}
}

class GlossaryTooltip extends StatelessComponent {
const GlossaryTooltip({required this.entry});

final GlossaryEntry entry;

@override
Component build(BuildContext context) {
return span(classes: 'tooltip', [
span(classes: 'tooltip-header', [text(entry.term)]),
span(classes: 'tooltip-content', [
text(entry.shortDescription),
text(' '),
a(
href: '/resources/glossary#${entry.id}',
attributes: {
'title':
'Learn more about \'${entry.term}\' and '
'find related resources.',
},
[text('Learn more')],
),
]),
]);
}
}
2 changes: 2 additions & 0 deletions site/lib/src/extensions/registry.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import 'package:jaspr_content/jaspr_content.dart';

import 'attribute_processor.dart';
import 'code_block_processor.dart';
import 'glossary_link_processor.dart';
import 'header_extractor.dart';
import 'header_processor.dart';
import 'table_processor.dart';
Expand All @@ -18,4 +19,5 @@ const List<PageExtension> allNodeProcessingExtensions = [
HeaderWrapperExtension(),
TableWrapperExtension(),
CodeBlockProcessor(),
GlossaryLinkProcessor(),
];
2 changes: 1 addition & 1 deletion site/lib/src/style_hash.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
// dart format off

/// The generated hash of the `main.css` file.
const generatedStylesHash = 'cD6c4zCH9whz';
const generatedStylesHash = 'mW0BCRz4bRll';
2 changes: 1 addition & 1 deletion src/content/app-architecture/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Within each layer, you should further separate your application by
feature or functionality. For example, your application's authentication logic
should be in a different class than the search logic.

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

## Layered architecture
Expand Down
2 changes: 1 addition & 1 deletion src/content/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ Coming from another platform? Check out Flutter for:

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

[Understanding constraints][]
: Once you understand that "Constraints
Expand Down
Loading