0 && styles.threeUpSplit,
+ )}
columnGap="none"
rowGap="none"
enableGutters={false}
>
- {visibleEntries.map((entry) => (
+ {beside.map((entry) => (
-
-
-
- {entry.isNew ? (
-
- New
-
- ) : null}
-
- {entry.topic}
-
-
- {entry.kind}
-
-
-
- {entry.title}
-
-
-
- {entry.description}
-
-
-
+ {renderEntryCard(entry)}
))}
@@ -863,6 +879,26 @@ function LearningHubIndexBody({
) : null}
+ {below.length > 0 ? (
+
+
+ {below.map((entry) => (
+
+ {renderEntryCard(entry)}
+
+ ))}
+
+
+ ) : null}
diff --git a/website/src/components/brand/PluginsCatalog.tsx b/website/src/components/brand/PluginsCatalog.tsx
index 1c51c94251..a4376f94ff 100644
--- a/website/src/components/brand/PluginsCatalog.tsx
+++ b/website/src/components/brand/PluginsCatalog.tsx
@@ -33,6 +33,7 @@ import { pageHref } from "./pageHref";
import type { SearchItem } from "./searchIndex";
import styles from "./styles/plugins.module.css";
import { useCatalogPageFocus } from "./useCatalogPageFocus";
+import { useCatalogSplit } from "./useCatalogSplit";
const CONTRIBUTE_URL =
"https://github.com/github/awesome-copilot/blob/main/docs/README.plugins.md#how-to-contribute";
@@ -208,6 +209,61 @@ export function PluginsCatalog({
page * pageSize,
);
+ const { filterNavRef, sideGridRef, wideGridRef, beside, below } =
+ useCatalogSplit(visiblePlugins, pageSize);
+
+ const renderPluginCard = (plugin: PluginItem) => (
+
+
+ {plugin.name}
+
+
+ {plugin.description}
+
+
+
+
+
+
+
+ {plugin.author?.name ? (
+
+ {plugin.author.name}
+
+ ) : null}
+
+ );
+
return (
-
+ {below.length > 0 ? (
+
+
+ {below.map((plugin) => (
+
+ {renderPluginCard(plugin)}
+
+ ))}
+
+
+ ) : null}
diff --git a/website/src/components/brand/SkillsCatalog.tsx b/website/src/components/brand/SkillsCatalog.tsx
index a08bcf6cd1..d6e98272d0 100644
--- a/website/src/components/brand/SkillsCatalog.tsx
+++ b/website/src/components/brand/SkillsCatalog.tsx
@@ -19,6 +19,7 @@ import React, { useMemo, useState } from "react";
import { PageShell } from "./PageShell";
import { useCatalogPageFocus } from "./useCatalogPageFocus";
+import { useCatalogSplit } from "./useCatalogSplit";
import { CatalogSortControl, CATALOG_SORT_OPTIONS } from "./CatalogSortControl";
import {
CatalogResourceSizeControl,
@@ -183,6 +184,58 @@ export function SkillsCatalog({
const page = Math.min(currentPage, pageCount);
const visibleSkills = sortedSkills.slice((page - 1) * pageSize, page * pageSize);
+ const { filterNavRef, sideGridRef, wideGridRef, beside, below } =
+ useCatalogSplit(visibleSkills, pageSize);
+
+ const renderSkillCard = (skill: SkillItem) => (
+
+
+ {skill.title}
+
+
+ {skill.description}
+
+
+
+
+
+
+
+
+
+ );
+
return (
-
+
+ {below.length > 0 ? (
+
+
+ {below.map((skill) => (
+
+ {renderSkillCard(skill)}
+
+ ))}
+
+
+ ) : null}
diff --git a/website/src/components/brand/styles/agents.module.css b/website/src/components/brand/styles/agents.module.css
index df8e237fc4..69c8f9c087 100644
--- a/website/src/components/brand/styles/agents.module.css
+++ b/website/src/components/brand/styles/agents.module.css
@@ -1085,6 +1085,11 @@
var(--brand-color-border-muted);
}
+ /* Split page: the wide grid below draws the line under these cards. */
+ .threeUpSplit {
+ border-block-end: 0;
+ }
+
/* Four-up stats row */
.fourUp .item {
border-block-start: 0;
@@ -1097,6 +1102,70 @@
}
}
+/* Cards past the end of the filter list move to a wider grid below the
+ catalog, inside the same frame: as many columns of at least 23rem as fit
+ (three in the 1280px frame). The frame draws the top and left gridlines,
+ and the right one beside an unfilled last row; each cell draws its own
+ right and bottom lines. It sits in the catalog's Section, so it shares the
+ 1280px cap and, below 120rem, the same gutter inset as .catalog. */
+.wideFrame {
+ width: auto;
+ container-type: inline-size;
+ border-inline-start: var(--brand-borderWidth-thin) solid
+ var(--brand-color-border-muted);
+ background-image: linear-gradient(
+ var(--brand-color-border-muted),
+ var(--brand-color-border-muted)
+ );
+ background-size: var(--brand-borderWidth-thin) 100%;
+ background-position: 100% 0;
+ background-repeat: no-repeat;
+}
+
+@media screen and (max-width: 119.99rem) {
+ .wideFrame {
+ margin-inline: var(--page-gutter);
+ }
+}
+
+.wideGrid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(23rem, 1fr));
+}
+
+.wideGrid .item {
+ border-block-start: 0;
+ border-inline-end: var(--brand-borderWidth-thin) solid
+ var(--brand-color-border-muted);
+ border-block-end: var(--brand-borderWidth-thin) solid
+ var(--brand-color-border-muted);
+}
+
+/* Except on the last page, a short last row in the three-column wide grid
+ (3 x 23rem of frame) is centered: one card sits under the middle column,
+ and two cards shift half a column, so the middle card's sides above meet
+ the middle of their top edges. The first shifted card draws its own left
+ line. Two columns never end short before the last page, because page sizes
+ are even and the side grid holds whole rows of two. */
+@container (width >= 69rem) {
+ .wideGridCentered > .col:nth-child(3n + 1):last-child {
+ position: relative;
+ inset-inline-start: 100%;
+ }
+
+ .wideGridCentered > .col:nth-child(3n + 1):nth-last-child(2),
+ .wideGridCentered > .col:nth-child(3n + 2):last-child {
+ position: relative;
+ inset-inline-start: 50%;
+ }
+
+ .wideGridCentered > .col:nth-child(3n + 1):last-child .item,
+ .wideGridCentered > .col:nth-child(3n + 1):nth-last-child(2) .item {
+ border-inline-start: var(--brand-borderWidth-thin) solid
+ var(--brand-color-border-muted);
+ }
+}
+
/*
* Responsive page gutters (mirrors styles.module.css; see there for the tier
* rationale): <768→20 · 768–1279→91 · 1280–1919→at least 152 · ≥1920→native 1280.
diff --git a/website/src/components/brand/styles/extensions.module.css b/website/src/components/brand/styles/extensions.module.css
index 53ba04d965..545d4f3df3 100644
--- a/website/src/components/brand/styles/extensions.module.css
+++ b/website/src/components/brand/styles/extensions.module.css
@@ -1189,6 +1189,11 @@
var(--brand-color-border-muted);
}
+ /* Split page: the wide grid below draws the line under these cards. */
+ .threeUpSplit {
+ border-block-end: 0;
+ }
+
/* Four-up stats row */
.fourUp .item {
border-block-start: 0;
@@ -1201,6 +1206,70 @@
}
}
+/* Cards past the end of the filter list move to a wider grid below the
+ catalog, inside the same frame: as many columns of at least 23rem as fit
+ (three in the 1280px frame). The frame draws the top and left gridlines,
+ and the right one beside an unfilled last row; each cell draws its own
+ right and bottom lines. It sits in the catalog's Section, so it shares the
+ 1280px cap and, below 120rem, the same gutter inset as .catalog. */
+.wideFrame {
+ width: auto;
+ container-type: inline-size;
+ border-inline-start: var(--brand-borderWidth-thin) solid
+ var(--brand-color-border-muted);
+ background-image: linear-gradient(
+ var(--brand-color-border-muted),
+ var(--brand-color-border-muted)
+ );
+ background-size: var(--brand-borderWidth-thin) 100%;
+ background-position: 100% 0;
+ background-repeat: no-repeat;
+}
+
+@media screen and (max-width: 119.99rem) {
+ .wideFrame {
+ margin-inline: var(--page-gutter);
+ }
+}
+
+.wideGrid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(23rem, 1fr));
+}
+
+.wideGrid .item {
+ border-block-start: 0;
+ border-inline-end: var(--brand-borderWidth-thin) solid
+ var(--brand-color-border-muted);
+ border-block-end: var(--brand-borderWidth-thin) solid
+ var(--brand-color-border-muted);
+}
+
+/* Except on the last page, a short last row in the three-column wide grid
+ (3 x 23rem of frame) is centered: one card sits under the middle column,
+ and two cards shift half a column, so the middle card's sides above meet
+ the middle of their top edges. The first shifted card draws its own left
+ line. Two columns never end short before the last page, because page sizes
+ are even and the side grid holds whole rows of two. */
+@container (width >= 69rem) {
+ .wideGridCentered > .col:nth-child(3n + 1):last-child {
+ position: relative;
+ inset-inline-start: 100%;
+ }
+
+ .wideGridCentered > .col:nth-child(3n + 1):nth-last-child(2),
+ .wideGridCentered > .col:nth-child(3n + 2):last-child {
+ position: relative;
+ inset-inline-start: 50%;
+ }
+
+ .wideGridCentered > .col:nth-child(3n + 1):last-child .item,
+ .wideGridCentered > .col:nth-child(3n + 1):nth-last-child(2) .item {
+ border-inline-start: var(--brand-borderWidth-thin) solid
+ var(--brand-color-border-muted);
+ }
+}
+
/*
* Responsive page gutters (mirrors styles.module.css; see there for the tier
* rationale): <768→20 · 768–1279→91 · 1280–1919→at least 152 · ≥1920→native 1280.
diff --git a/website/src/components/brand/styles/instructions.module.css b/website/src/components/brand/styles/instructions.module.css
index feb93ca258..c6723042d5 100644
--- a/website/src/components/brand/styles/instructions.module.css
+++ b/website/src/components/brand/styles/instructions.module.css
@@ -1074,6 +1074,11 @@
var(--brand-color-border-muted);
}
+ /* Split page: the wide grid below draws the line under these cards. */
+ .threeUpSplit {
+ border-block-end: 0;
+ }
+
/* Four-up stats row */
.fourUp .item {
border-block-start: 0;
@@ -1086,6 +1091,70 @@
}
}
+/* Cards past the end of the filter list move to a wider grid below the
+ catalog, inside the same frame: as many columns of at least 23rem as fit
+ (three in the 1280px frame). The frame draws the top and left gridlines,
+ and the right one beside an unfilled last row; each cell draws its own
+ right and bottom lines. It sits in the catalog's Section, so it shares the
+ 1280px cap and, below 120rem, the same gutter inset as .catalog. */
+.wideFrame {
+ width: auto;
+ container-type: inline-size;
+ border-inline-start: var(--brand-borderWidth-thin) solid
+ var(--brand-color-border-muted);
+ background-image: linear-gradient(
+ var(--brand-color-border-muted),
+ var(--brand-color-border-muted)
+ );
+ background-size: var(--brand-borderWidth-thin) 100%;
+ background-position: 100% 0;
+ background-repeat: no-repeat;
+}
+
+@media screen and (max-width: 119.99rem) {
+ .wideFrame {
+ margin-inline: var(--page-gutter);
+ }
+}
+
+.wideGrid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(23rem, 1fr));
+}
+
+.wideGrid .item {
+ border-block-start: 0;
+ border-inline-end: var(--brand-borderWidth-thin) solid
+ var(--brand-color-border-muted);
+ border-block-end: var(--brand-borderWidth-thin) solid
+ var(--brand-color-border-muted);
+}
+
+/* Except on the last page, a short last row in the three-column wide grid
+ (3 x 23rem of frame) is centered: one card sits under the middle column,
+ and two cards shift half a column, so the middle card's sides above meet
+ the middle of their top edges. The first shifted card draws its own left
+ line. Two columns never end short before the last page, because page sizes
+ are even and the side grid holds whole rows of two. */
+@container (width >= 69rem) {
+ .wideGridCentered > .col:nth-child(3n + 1):last-child {
+ position: relative;
+ inset-inline-start: 100%;
+ }
+
+ .wideGridCentered > .col:nth-child(3n + 1):nth-last-child(2),
+ .wideGridCentered > .col:nth-child(3n + 2):last-child {
+ position: relative;
+ inset-inline-start: 50%;
+ }
+
+ .wideGridCentered > .col:nth-child(3n + 1):last-child .item,
+ .wideGridCentered > .col:nth-child(3n + 1):nth-last-child(2) .item {
+ border-inline-start: var(--brand-borderWidth-thin) solid
+ var(--brand-color-border-muted);
+ }
+}
+
/*
* Responsive page gutters (mirrors styles.module.css; see there for the tier
* rationale): <768→20 · 768–1279→91 · 1280–1919→at least 152 · ≥1920→native 1280.
diff --git a/website/src/components/brand/styles/learning-hub-copilot-app.module.css b/website/src/components/brand/styles/learning-hub-copilot-app.module.css
index 8a86092015..cca3c69a26 100644
--- a/website/src/components/brand/styles/learning-hub-copilot-app.module.css
+++ b/website/src/components/brand/styles/learning-hub-copilot-app.module.css
@@ -1919,6 +1919,11 @@
var(--brand-color-border-muted);
}
+ /* Split page: the wide grid below draws the line under these cards. */
+ .threeUpSplit {
+ border-block-end: 0;
+ }
+
/* Four-up stats row */
.fourUp .item {
border-block-start: 0;
@@ -1931,6 +1936,70 @@
}
}
+/* Cards past the end of the filter list move to a wider grid below the
+ catalog, inside the same frame: as many columns of at least 23rem as fit
+ (three in the 1280px frame). The frame draws the top and left gridlines,
+ and the right one beside an unfilled last row; each cell draws its own
+ right and bottom lines. It sits in the catalog's Section, so it shares the
+ 1280px cap and, below 120rem, the same gutter inset as .catalog. */
+.wideFrame {
+ width: auto;
+ container-type: inline-size;
+ border-inline-start: var(--brand-borderWidth-thin) solid
+ var(--brand-color-border-muted);
+ background-image: linear-gradient(
+ var(--brand-color-border-muted),
+ var(--brand-color-border-muted)
+ );
+ background-size: var(--brand-borderWidth-thin) 100%;
+ background-position: 100% 0;
+ background-repeat: no-repeat;
+}
+
+@media screen and (max-width: 119.99rem) {
+ .wideFrame {
+ margin-inline: var(--page-gutter);
+ }
+}
+
+.wideGrid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(23rem, 1fr));
+}
+
+.wideGrid .item {
+ border-block-start: 0;
+ border-inline-end: var(--brand-borderWidth-thin) solid
+ var(--brand-color-border-muted);
+ border-block-end: var(--brand-borderWidth-thin) solid
+ var(--brand-color-border-muted);
+}
+
+/* Except on the last page, a short last row in the three-column wide grid
+ (3 x 23rem of frame) is centered: one card sits under the middle column,
+ and two cards shift half a column, so the middle card's sides above meet
+ the middle of their top edges. The first shifted card draws its own left
+ line. Two columns never end short before the last page, because page sizes
+ are even and the side grid holds whole rows of two. */
+@container (width >= 69rem) {
+ .wideGridCentered > .col:nth-child(3n + 1):last-child {
+ position: relative;
+ inset-inline-start: 100%;
+ }
+
+ .wideGridCentered > .col:nth-child(3n + 1):nth-last-child(2),
+ .wideGridCentered > .col:nth-child(3n + 2):last-child {
+ position: relative;
+ inset-inline-start: 50%;
+ }
+
+ .wideGridCentered > .col:nth-child(3n + 1):last-child .item,
+ .wideGridCentered > .col:nth-child(3n + 1):nth-last-child(2) .item {
+ border-inline-start: var(--brand-borderWidth-thin) solid
+ var(--brand-color-border-muted);
+ }
+}
+
/*
* Responsive page gutters (mirrors styles.module.css; see there for the tier
* rationale): <768→20 · 768–1279→91 · 1280–1919→at least 152 · ≥1920→native 1280.
diff --git a/website/src/components/brand/styles/plugins.module.css b/website/src/components/brand/styles/plugins.module.css
index a1a6542491..dca7ce5167 100644
--- a/website/src/components/brand/styles/plugins.module.css
+++ b/website/src/components/brand/styles/plugins.module.css
@@ -1116,6 +1116,11 @@
var(--brand-color-border-muted);
}
+ /* Split page: the wide grid below draws the line under these cards. */
+ .threeUpSplit {
+ border-block-end: 0;
+ }
+
/* Four-up stats row */
.fourUp .item {
border-block-start: 0;
@@ -1128,6 +1133,70 @@
}
}
+/* Cards past the end of the filter list move to a wider grid below the
+ catalog, inside the same frame: as many columns of at least 23rem as fit
+ (three in the 1280px frame). The frame draws the top and left gridlines,
+ and the right one beside an unfilled last row; each cell draws its own
+ right and bottom lines. It sits in the catalog's Section, so it shares the
+ 1280px cap and, below 120rem, the same gutter inset as .catalog. */
+.wideFrame {
+ width: auto;
+ container-type: inline-size;
+ border-inline-start: var(--brand-borderWidth-thin) solid
+ var(--brand-color-border-muted);
+ background-image: linear-gradient(
+ var(--brand-color-border-muted),
+ var(--brand-color-border-muted)
+ );
+ background-size: var(--brand-borderWidth-thin) 100%;
+ background-position: 100% 0;
+ background-repeat: no-repeat;
+}
+
+@media screen and (max-width: 119.99rem) {
+ .wideFrame {
+ margin-inline: var(--page-gutter);
+ }
+}
+
+.wideGrid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(23rem, 1fr));
+}
+
+.wideGrid .item {
+ border-block-start: 0;
+ border-inline-end: var(--brand-borderWidth-thin) solid
+ var(--brand-color-border-muted);
+ border-block-end: var(--brand-borderWidth-thin) solid
+ var(--brand-color-border-muted);
+}
+
+/* Except on the last page, a short last row in the three-column wide grid
+ (3 x 23rem of frame) is centered: one card sits under the middle column,
+ and two cards shift half a column, so the middle card's sides above meet
+ the middle of their top edges. The first shifted card draws its own left
+ line. Two columns never end short before the last page, because page sizes
+ are even and the side grid holds whole rows of two. */
+@container (width >= 69rem) {
+ .wideGridCentered > .col:nth-child(3n + 1):last-child {
+ position: relative;
+ inset-inline-start: 100%;
+ }
+
+ .wideGridCentered > .col:nth-child(3n + 1):nth-last-child(2),
+ .wideGridCentered > .col:nth-child(3n + 2):last-child {
+ position: relative;
+ inset-inline-start: 50%;
+ }
+
+ .wideGridCentered > .col:nth-child(3n + 1):last-child .item,
+ .wideGridCentered > .col:nth-child(3n + 1):nth-last-child(2) .item {
+ border-inline-start: var(--brand-borderWidth-thin) solid
+ var(--brand-color-border-muted);
+ }
+}
+
/*
* Responsive page gutters (mirrors styles.module.css; see there for the tier
* rationale): <768→20 · 768–1279→91 · 1280–1919→at least 152 · ≥1920→native 1280.
diff --git a/website/src/components/brand/styles/skills.module.css b/website/src/components/brand/styles/skills.module.css
index 62754643f8..54092099e4 100644
--- a/website/src/components/brand/styles/skills.module.css
+++ b/website/src/components/brand/styles/skills.module.css
@@ -1104,6 +1104,11 @@
var(--brand-color-border-muted);
}
+ /* Split page: the wide grid below draws the line under these cards. */
+ .threeUpSplit {
+ border-block-end: 0;
+ }
+
/* Four-up stats row */
.fourUp .item {
border-block-start: 0;
@@ -1116,6 +1121,70 @@
}
}
+/* Cards past the end of the filter list move to a wider grid below the
+ catalog, inside the same frame: as many columns of at least 23rem as fit
+ (three in the 1280px frame). The frame draws the top and left gridlines,
+ and the right one beside an unfilled last row; each cell draws its own
+ right and bottom lines. It sits in the catalog's Section, so it shares the
+ 1280px cap and, below 120rem, the same gutter inset as .catalog. */
+.wideFrame {
+ width: auto;
+ container-type: inline-size;
+ border-inline-start: var(--brand-borderWidth-thin) solid
+ var(--brand-color-border-muted);
+ background-image: linear-gradient(
+ var(--brand-color-border-muted),
+ var(--brand-color-border-muted)
+ );
+ background-size: var(--brand-borderWidth-thin) 100%;
+ background-position: 100% 0;
+ background-repeat: no-repeat;
+}
+
+@media screen and (max-width: 119.99rem) {
+ .wideFrame {
+ margin-inline: var(--page-gutter);
+ }
+}
+
+.wideGrid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(23rem, 1fr));
+}
+
+.wideGrid .item {
+ border-block-start: 0;
+ border-inline-end: var(--brand-borderWidth-thin) solid
+ var(--brand-color-border-muted);
+ border-block-end: var(--brand-borderWidth-thin) solid
+ var(--brand-color-border-muted);
+}
+
+/* Except on the last page, a short last row in the three-column wide grid
+ (3 x 23rem of frame) is centered: one card sits under the middle column,
+ and two cards shift half a column, so the middle card's sides above meet
+ the middle of their top edges. The first shifted card draws its own left
+ line. Two columns never end short before the last page, because page sizes
+ are even and the side grid holds whole rows of two. */
+@container (width >= 69rem) {
+ .wideGridCentered > .col:nth-child(3n + 1):last-child {
+ position: relative;
+ inset-inline-start: 100%;
+ }
+
+ .wideGridCentered > .col:nth-child(3n + 1):nth-last-child(2),
+ .wideGridCentered > .col:nth-child(3n + 2):last-child {
+ position: relative;
+ inset-inline-start: 50%;
+ }
+
+ .wideGridCentered > .col:nth-child(3n + 1):last-child .item,
+ .wideGridCentered > .col:nth-child(3n + 1):nth-last-child(2) .item {
+ border-inline-start: var(--brand-borderWidth-thin) solid
+ var(--brand-color-border-muted);
+ }
+}
+
/*
* Responsive page gutters (mirrors styles.module.css; see there for the tier
* rationale): <768→20 · 768–1279→91 · 1280–1919→at least 152 · ≥1920→native 1280.
diff --git a/website/src/components/brand/useCatalogSplit.ts b/website/src/components/brand/useCatalogSplit.ts
new file mode 100644
index 0000000000..70539703df
--- /dev/null
+++ b/website/src/components/brand/useCatalogSplit.ts
@@ -0,0 +1,202 @@
+import {
+ useEffect,
+ useEffectEvent,
+ useLayoutEffect,
+ useRef,
+ useState,
+} from "react";
+
+import {
+ DEFAULT_CATALOG_RESOURCE_SIZE,
+ type CatalogResourceSize,
+} from "./CatalogResourceSizeControl";
+
+/** Everything a split decision depends on, apart from the page's scrollbar. */
+type SplitInputs = {
+ viewport: string;
+ navEnd: number;
+ length: number;
+ first: unknown;
+ last: unknown;
+};
+
+/**
+ * Splits a catalog page's cards between the grid beside the filter sidebar and
+ * a wider grid below it. A row stays beside the sidebar while it starts above
+ * the end of the filter list, so the wide grid begins with the first row the
+ * sidebar no longer reaches. The default page size never splits, and when the
+ * sidebar stacks above the cards (narrow screens), every card stays in the
+ * side grid.
+ */
+export function useCatalogSplit(
+ items: readonly T[],
+ pageSize: CatalogResourceSize,
+) {
+ const filterNavRef = useRef(null);
+ const sideGridRef = useRef(null);
+ const wideGridRef = useRef(null);
+ // A card that changes grids remounts, so its focused control is lost.
+ const pendingFocus = useRef(null);
+ const splits = pageSize !== DEFAULT_CATALOG_RESOURCE_SIZE;
+ // Infinity until a measurement cuts the list, so a stacked layout or a
+ // longer list never mounts a wide grid only to tear it down again.
+ const [besideCount, setBesideCount] = useState(Infinity);
+ const shown = splits ? Math.min(besideCount, items.length) : items.length;
+ const lastChange = useRef<{
+ inputs: SplitInputs;
+ from: number;
+ to: number;
+ } | null>(null);
+
+ const measure = useEffectEvent(() => {
+ const nav = filterNavRef.current;
+ const grid = sideGridRef.current;
+ if (!splits || !nav || !grid || items.length === 0) return;
+ const navEnd = filterListEnd(nav);
+ const next = countBeside(nav, grid, navEnd, shown, items.length);
+ if (next === besideCount) return;
+ // Keep a larger stored count when it already shows the right cards, but
+ // store the first finite count, so a longer list later grows row by row.
+ if (next === shown && besideCount !== Infinity) return;
+ // Moving cards changes the page height, which can add or remove the
+ // vertical scrollbar and so the grid's width. If a change would only undo
+ // the previous one while nothing else moved, keep the current split.
+ const inputs: SplitInputs = {
+ viewport: `${window.innerWidth}x${window.innerHeight}`,
+ navEnd: Math.round(navEnd - nav.getBoundingClientRect().top),
+ length: items.length,
+ first: items[0],
+ last: items[items.length - 1],
+ };
+ const previous = lastChange.current;
+ if (
+ previous &&
+ previous.from === next &&
+ previous.to === besideCount &&
+ sameInputs(previous.inputs, inputs)
+ ) {
+ return;
+ }
+ lastChange.current = { inputs, from: besideCount, to: next };
+ pendingFocus.current = focusSpot([grid, wideGridRef.current]);
+ setBesideCount(next);
+ });
+
+ // Put focus back on the same card and control after a move, unless the
+ // reader has already focused something else.
+ useLayoutEffect(() => {
+ const spot = pendingFocus.current;
+ pendingFocus.current = null;
+ if (!spot || document.activeElement !== document.body) return;
+ restoreFocus([sideGridRef.current, wideGridRef.current], spot);
+ });
+
+ // Page, sort, filter, page size, and "Show more" changes all re-render, so
+ // measure after every render, before the browser paints.
+ useLayoutEffect(() => measure());
+
+ // Viewport changes resize the side grid, and font loads can reflow the
+ // filter groups without a render, so watch both.
+ useEffect(() => {
+ const grid = sideGridRef.current;
+ const nav = filterNavRef.current;
+ if (!grid || !nav) return;
+ const observer = new ResizeObserver(() => measure());
+ observer.observe(grid);
+ for (const group of nav.lastElementChild?.children ?? []) {
+ observer.observe(group);
+ }
+ return () => observer.disconnect();
+ }, []);
+
+ return {
+ filterNavRef,
+ sideGridRef,
+ wideGridRef,
+ beside: items.slice(0, shown),
+ below: items.slice(shown),
+ };
+}
+
+/**
+ * The sidebar box stretches to the catalog's height, so the filter list ends
+ * at its last group (or Clear all) plus the sidebar's bottom padding.
+ */
+function filterListEnd(nav: HTMLElement) {
+ const list = nav.lastElementChild?.lastElementChild ?? nav;
+ return (
+ list.getBoundingClientRect().bottom +
+ parseFloat(getComputedStyle(nav).paddingBlockEnd)
+ );
+}
+
+function countBeside(
+ nav: HTMLElement,
+ grid: HTMLElement,
+ navEnd: number,
+ shown: number,
+ total: number,
+) {
+ // Stacked layout: the sidebar sits above the cards, not beside them.
+ const gridLeft = grid.getBoundingClientRect().left;
+ if (nav.getBoundingClientRect().right > gridLeft + 1) return Infinity;
+ const cells = Array.from(grid.children, (cell) =>
+ cell.getBoundingClientRect(),
+ );
+ if (cells.length === 0) return Math.min(total, 1);
+ const columns = Math.max(
+ 1,
+ Math.round(grid.getBoundingClientRect().width / cells[0].width),
+ );
+ const rows = Math.ceil(cells.length / columns);
+ const rowsBeside = new Set(
+ cells.filter((cell) => cell.top < navEnd).map((cell) => cell.top),
+ ).size;
+ // Cut whole rows that start at or below the end of the filter list.
+ if (rowsBeside < rows) return rowsBeside * columns;
+ if (shown >= total) return shown;
+ // Every row shown so far starts beside the filters. Fill a short last row,
+ // or add one more row while the grid still ends above the filters' end
+ // (rows touch, so the next row would start where this one ends).
+ if (cells.length % columns !== 0) return Math.min(total, rows * columns);
+ if (cells[cells.length - 1].bottom < navEnd) {
+ return Math.min(total, shown + columns);
+ }
+ return shown;
+}
+
+const FOCUSABLE = "a[href], button, [tabindex]";
+
+/** Which card (counting across both grids) and which of its controls. */
+type FocusSpot = { card: number; control: number };
+
+function focusSpot(grids: (Element | null)[]): FocusSpot | null {
+ const active = document.activeElement;
+ let card = 0;
+ for (const grid of grids) {
+ for (const cell of grid?.children ?? []) {
+ if (active && cell.contains(active)) {
+ const controls = Array.from(cell.querySelectorAll(FOCUSABLE));
+ return { card, control: controls.indexOf(active) };
+ }
+ card += 1;
+ }
+ }
+ return null;
+}
+
+function restoreFocus(grids: (Element | null)[], spot: FocusSpot) {
+ const cells = grids.flatMap((grid) => Array.from(grid?.children ?? []));
+ const controls = cells[spot.card]?.querySelectorAll(FOCUSABLE);
+ controls?.[spot.control]?.focus();
+}
+
+function sameInputs(a: SplitInputs, b: SplitInputs) {
+ return (
+ a.viewport === b.viewport &&
+ a.navEnd === b.navEnd &&
+ a.length === b.length &&
+ a.first === b.first &&
+ a.last === b.last
+ );
+}