Skip to content
Open
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
29 changes: 10 additions & 19 deletions packages/site_shared/lib/_sass/components/_blog.scss
Original file line number Diff line number Diff line change
Expand Up @@ -157,14 +157,11 @@
margin-top: 0.25rem;
margin-bottom: 0.5rem;
color: var(--site-base-fgColor);
}

a {
color: inherit;
text-decoration: none;

&:hover {
color: var(--site-primary-color);
}
&:hover, &:focus-visible {
.blog-card-title {
color: var(--site-link-fgColor);
}
}

Expand Down Expand Up @@ -238,7 +235,7 @@
&:hover {
transform: translateY(-4px);
box-shadow: 0 10px 20px -5px rgba(0, 0, 0, 0.1);
border-color: var(--site-primary-color);
border-color: var(--site-link-fgColor);
}

.blog-card-image {
Expand Down Expand Up @@ -365,16 +362,6 @@
}
}

> a {
text-decoration: none;
color: inherit;
display: block;

&:hover .post-info-name {
color: var(--site-primary-color);
}
}

.post-info-author {
display: flex;
align-items: center;
Expand Down Expand Up @@ -434,6 +421,10 @@
font-weight: var(--site-fontWeight-bold);
}

a:hover {
color: var(--site-link-fgColor);
}

.post-info-author-name {
white-space: nowrap;
}
Expand Down Expand Up @@ -485,7 +476,7 @@
color: var(--site-base-fgColor);

&:hover {
color: var(--site-primary-color);
color: var(--site-link-fgColor);
}
}
}
Expand Down
10 changes: 10 additions & 0 deletions packages/site_shared/lib/_sass/components/_code.scss
Original file line number Diff line number Diff line change
Expand Up @@ -469,3 +469,13 @@ iframe[src^="https://dartpad"] {
}
}
}

body.dark-mode {
.opal,
.opal span {
color: var(--opal-dark-color) !important;
font-style: var(--opal-dark-font-style) !important;
font-weight: var(--opal-dark-font-weight) !important;
text-decoration: var(--opal-dark-text-decoration) !important;
}
}
8 changes: 8 additions & 0 deletions packages/site_shared/lib/_sass/components/_theming.scss
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
body:not(.dark-mode) .dark-mode-visible {
display: none !important;
}

body.dark-mode .light-mode-visible {
display: none !important;
}

#theme-switcher {
position: relative;

Expand Down
37 changes: 9 additions & 28 deletions packages/site_shared/lib/components/blog/blog_card.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,42 +9,39 @@ import 'package:jaspr_content/jaspr_content.dart';

import '../../blog.dart';
import '../../util.dart';
import 'blog_card_layout.dart';

class BlogCard extends StatelessComponent {
const BlogCard({
required this.post,
required this.url,
this.className,
this.priority = BlogCardPriority.normal,
this.layout = .grid,
super.key,
});

final Post post;
final String url;
final String? className;
final BlogCardPriority priority;
final BlogCardLayout layout;

@override
Component build(BuildContext context) {
final authors = context.page.authorsByIds(post.authorIds);
return a(
href: url,
classes: ['blog-card', ?className].toClasses,
classes: ['blog-card', layout.className].toClasses,
attributes: {'data-category': post.category},
[
if (post.coverImage?.url case final postImage?)
div(classes: 'blog-card-image', [
img(
src: postImage,
// The link already includes the post title below the image.
// The image is decorative since the title follows it.
alt: '',
loading: switch (priority) {
.featured => MediaLoading.eager,
.high => MediaLoading.lazy,
.normal => MediaLoading.lazy,
},
loading: layout == .featured
? MediaLoading.eager
: MediaLoading.lazy,
attributes: {
if (priority == .featured)
if (layout == .featured)
'fetchpriority': 'high'
else
'decoding': 'async',
Expand Down Expand Up @@ -96,19 +93,3 @@ class BlogCard extends StatelessComponent {
);
}
}

/// Priority levels for blog cards.
///
/// Used to configure loading behavior of their content, particularly images.
enum BlogCardPriority {
/// A featured blog card.
featured,

/// A high-priority blog card.
///
/// Often the first 5 or so cards.
high,

/// A normal-priority blog card.
normal,
}
37 changes: 37 additions & 0 deletions packages/site_shared/lib/components/blog/blog_card_layout.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright 2026, 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.

/// The layout of a card in the blog index,
/// determined by its position among the visible cards.
enum BlogCardLayout {
/// A full-width card with a large image and title,
/// used to highlight the most prominent post.
featured,

/// A bordered tile that's arranged in a grid with other cards.
grid,

/// A full-width row with a small image beside the post details.
list,
;

/// The CSS class applied to a `.blog-card` element to
/// style it with this layout.
String get className => switch (this) {
.featured => 'layout-featured',
.grid => 'layout-grid',
.list => 'layout-list',
};

/// Returns the layout for the card at the specified [index]
/// among the visible cards.
///
/// The first card is [featured], the next four are [grid],
/// and all remaining cards are [list].
static BlogCardLayout forIndex(int index) => switch (index) {
0 => featured,
< 5 => grid,
_ => list,
};
}
15 changes: 4 additions & 11 deletions packages/site_shared/lib/components/blog/blog_index.dart
Original file line number Diff line number Diff line change
Expand Up @@ -65,18 +65,11 @@ class BlogIndex extends StatelessComponent {
classes: 'blog-posts-grid',
attributes: {'data-selected': 'all'},
[
for (var i = 0; i < posts.length; i++)
for (final (i, (:post, :url)) in posts.indexed)
BlogCard(
post: posts[i].post,
url: posts[i].url,
priority: switch (i) {
0 => BlogCardPriority.featured,
< 5 => BlogCardPriority.high,
_ => BlogCardPriority.normal,
},
className: i == 0
? 'layout-featured'
: (i < 5 ? 'layout-grid' : 'layout-list'),
post: post,
url: url,
layout: .forIndex(i),
),
],
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,10 @@ class BlogNextPosts extends StatelessComponent {
return section(classes: 'blog-next-posts', [
h2([.text(nextPostsTitle)]),
div(classes: 'blog-posts-grid', [
for (final post in nextPosts)
for (final (:post, :url) in nextPosts)
BlogCard(
post: post.post,
url: post.url,
className: 'layout-grid',
post: post,
url: url,
),
]),
]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import 'package:jaspr/jaspr.dart';
import 'package:universal_web/web.dart' as web;

import '../../../util.dart';
import '../blog_card_layout.dart';

class BlogCategory {
const BlogCategory({
Expand Down Expand Up @@ -81,6 +82,10 @@ class _BlogCategoriesState extends State<BlogCategories> {
.where((c) => c.slug == slug)
.firstOrNull;

// The cards already have the layout for the selected category,
// including the server-rendered layout when no category is selected.
if (category == selectedCategory) return;

setState(() {
selectedCategory = category;
});
Expand All @@ -105,38 +110,25 @@ class _BlogCategoriesState extends State<BlogCategories> {

void _applyLayout(BlogCategory? category) {
final container = web.document.getElementById('blog-container');
if (container != null) {
if (category == null) {
container.removeAttribute('data-selected');
} else {
container.setAttribute('data-selected', category.slug);
}
if (container == null) return;

container.setAttribute('data-selected', category?.slug ?? 'all');

final cards = container.querySelectorAll('.blog-card');
final showFeatured = category?.showFeatured ?? true;
var visibleCount = showFeatured ? 0 : 1;

for (var cardIndex = 0; cardIndex < cards.length; cardIndex += 1) {
final card = cards.item(cardIndex) as web.Element;
final cardCategory = card.getAttribute('data-category');

final isVisible = category == null || cardCategory == category.slug;
final targetLayout = isVisible
? BlogCardLayout.forIndex(visibleCount++)
: null;

final cards = container.querySelectorAll('.blog-card');
final showFeatured = category?.showFeatured ?? true;
var visibleCount = showFeatured ? 0 : 1;

for (var i = 0; i < cards.length; i++) {
final card = cards.item(i) as web.Element;
final cardCategory = card.getAttribute('data-category');

final isVisible = category == null || cardCategory == category.slug;

// Remove existing layout classes.
card.classList.remove('layout-featured');
card.classList.remove('layout-grid');
card.classList.remove('layout-list');

if (isVisible) {
if (visibleCount == 0) {
card.classList.add('layout-featured');
} else if (visibleCount < 5) {
card.classList.add('layout-grid');
} else {
card.classList.add('layout-list');
}
visibleCount++;
}
for (final layout in BlogCardLayout.values) {
card.classList.toggle(layout.className, layout == targetLayout);
}
}
}
Expand Down
17 changes: 12 additions & 5 deletions packages/site_shared/lib/components/common/dropdown.dart
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ final class DropdownState extends State<Dropdown> {
return GlobalEventListener(
onClick: (event) {
if (!_expanded) return;
final target = event.target as web.HTMLElement?;
final target = event.target as web.Element?;
if (target == null || target.closest('#${component.id}') == null) {
toggle(to: false);
}
Comment thread
parlough marked this conversation as resolved.
Expand All @@ -58,7 +58,7 @@ final class DropdownState extends State<Dropdown> {
},
'focusout': (e) {
final relatedTarget =
(e as web.FocusEvent).relatedTarget as web.HTMLElement?;
(e as web.FocusEvent).relatedTarget as web.Element?;
if (relatedTarget == null ||
relatedTarget.closest('#${component.id}') == null) {
toggle(to: false);
Comment thread
parlough marked this conversation as resolved.
Expand All @@ -79,9 +79,16 @@ final class DropdownState extends State<Dropdown> {
},
child: component.toggle,
),
div(id: '${component.id}-content', classes: 'dropdown-content', [
component.content,
]),
div(
id: '${component.id}-content',
classes: 'dropdown-content',
// Make the content focusable, but not tabbable,
// so clicking an area of it that isn't otherwise focusable keeps
// focus within the dropdown rather than triggering the `focusout`
// handler above with a null `relatedTarget` and closing it.
attributes: const {'tabindex': '-1'},
[component.content],
),
],
),
);
Expand Down
Loading
Loading