Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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 @@ -38,6 +38,7 @@
@use 'components/tags';
@use 'components/theming';
@use 'components/toc';
@use 'components/tooltip';
@use 'components/trailing';

@use 'pages/dash';
Expand Down
8 changes: 6 additions & 2 deletions site/lib/_sass/components/_glossary.scss
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ body.glossary-page main {
min-height: 8rem;

.initial-content {

// Only show the first paragraph if collapsed.
> :not(:first-child) {
display: none;
Expand All @@ -29,7 +30,9 @@ body.glossary-page main {
}

.expand-button {
&:hover, &:focus-within {

&:hover,
&:focus-within {
transition: transform .25s ease-out;
}
}
Expand Down Expand Up @@ -61,7 +64,8 @@ body.glossary-page main {
}
}

.initial-content, .expandable-content {
.initial-content,
.expandable-content {
> :first-child {
margin-top: 0;
}
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 @@ -46,6 +46,7 @@ void _setUpSite() {
_setUpExpandableCards();
_setUpTableOfContents();
_setUpReleaseTags();
_setUpTooltips();
}

void _setUpSidenav() {
Expand Down Expand Up @@ -543,3 +544,93 @@ void _setUpReleaseTags() {
fetchVersion('beta');
fetchVersion('dev');
}

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.remove('visible');
} else {
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;

if (tooltipLeft < containerRect.left) {
final offset = containerRect.left - tooltipLeft;
tooltip.style.left = 'calc(50% + ${offset}px)';
tooltip.dataset['adjusted'] = offset.toString();
} else if (tooltipRight > containerRect.right) {
final offset = tooltipRight - containerRect.right;
tooltip.style.left = 'calc(50% - ${offset}px)';
tooltip.dataset['adjusted'] = (-offset).toString();
} else {
tooltip.style.left = '50%';
tooltip.dataset['adjusted'] = '0';
}
}
90 changes: 90 additions & 0 deletions site/lib/src/extensions/glossary_link_processor.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
// for details. 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}', [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 = 'YoU2normseCd';
const generatedStylesHash = 'EcoGkjHB12Fs';