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 @@ -38,6 +38,7 @@
@use 'components/tags';
@use 'components/theming';
@use 'components/toc';
@use 'components/tooltip';
@use 'components/trailing';

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

.expand-button {
&:hover, &:focus-within {
&:hover,
&:focus-within {
transition: transform .25s ease-out;
}
}
Expand Down Expand Up @@ -61,7 +62,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;
}
}
}
99 changes: 99 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 @@ -349,6 +350,7 @@ void _setUpExpandableCards() {
currentFragment = currentFragment.substring(1);
}
final expandableCards = web.document.querySelectorAll('.expandable-card');
web.Element? targetCard;

for (var i = 0; i < expandableCards.length; i++) {
final card = expandableCards.item(i) as web.Element;
Expand All @@ -372,8 +374,15 @@ void _setUpExpandableCards() {
if (card.id != currentFragment) {
card.classList.add('collapsed');
expandButton.ariaExpanded = 'false';
} else {
targetCard = card;
}
}

if (targetCard != null) {
// Scroll the expanded card into view.
targetCard.scrollIntoView();
}
}

void _setUpTableOfContents() {
Expand Down Expand Up @@ -543,3 +552,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';
}
}
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 (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}',
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 = 'YoU2normseCd';
const generatedStylesHash = 'EcoGkjHB12Fs';