fix(website): collapse long filter facets and unpin detail-page footer

Two issues surfaced by review of the redesigned site:

Catalog facets rendered every option. The prototype's filter groups were
built from small hardcoded arrays, but real data produces 193 tool options
on /agents/ and 245 "Applies to" values on /instructions/. The sidebar grew
to ~10,000px and stretched the whole catalog row, pushing the (already
present) pagination control far below the fold so it read as missing.

Adopt the prototype's own solution for this, which it had already applied to
the extensions page: collapse groups past 10 options behind a "Show N more"
toggle, and cap .filterOptions with an internal scroll area. Ported verbatim
to the agents, instructions, skills, and plugins catalogs.

Detail pages scroll inside .scrollHost rather than the document, but the
footer came from PageShell, outside that element, so it stayed pinned over
the content instead of appearing at the end. The prototype renders its
footer inside the scroll host; PageShell now takes a renderFooter flag so
DetailChassis can do the same. LearningArticleLayout already did this.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6
This commit is contained in:
Aaron Powell
2026-08-15 07:42:30 +10:00
parent 49cdb7e010
commit 21db4711f5
21 changed files with 3065 additions and 77 deletions
+53 -19
View File
@@ -88,6 +88,13 @@ const emptyFilters: FilterState = { model: [], tools: [], updated: [] };
const PAGE_SIZE = 6;
/**
* Long facets collapse behind a "Show N more" toggle, matching the prototype's
* extensions page. Real data produces far longer option lists than the
* prototype's hardcoded arrays, so every catalog needs this.
*/
const FILTER_COLLAPSE_LIMIT = 10;
/**
* The Agents catalog, ported from the design prototype's `agents.tsx`. The
* prototype's hardcoded array and hardcoded model/capability options are
@@ -109,6 +116,12 @@ export function AgentsCatalog({
const [currentPage, setCurrentPage] = useState(1);
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
const [filters, setFilters] = useState<FilterState>(emptyFilters);
const [expandedGroups, setExpandedGroups] = useState<
Record<FilterGroupId, boolean>
>({ model: false, tools: false, updated: false });
const toggleGroupExpanded = (groupId: FilterGroupId) => {
setExpandedGroups((prev) => ({ ...prev, [groupId]: !prev[groupId] }));
};
const filterGroups = useMemo<
{ id: FilterGroupId; label: string; options: string[] }[]
@@ -257,26 +270,47 @@ export function AgentsCatalog({
!mobileFiltersOpen && styles.filterBodyCollapsed,
)}
>
{filterGroups.map((group) => (
<div className={styles.filterGroup} key={group.id}>
<Text as="h2" size="100" className={styles.filterHeading}>
{group.label}
</Text>
<div className={styles.filterOptions}>
{group.options.map((option) => (
<div className={styles.filterOption} key={option}>
<FormControl>
<Checkbox
checked={filters[group.id].includes(option)}
onChange={() => toggleFilter(group.id, option)}
/>
<FormControl.Label>{option}</FormControl.Label>
</FormControl>
</div>
))}
{filterGroups.map((group) => {
const isExpandable =
group.options.length > FILTER_COLLAPSE_LIMIT;
const expanded = expandedGroups[group.id];
const visibleOptions =
isExpandable && !expanded
? group.options.slice(0, FILTER_COLLAPSE_LIMIT)
: group.options;
return (
<div className={styles.filterGroup} key={group.id}>
<Text as="h2" size="100" className={styles.filterHeading}>
{group.label}
</Text>
<div className={styles.filterOptions}>
{visibleOptions.map((option) => (
<div className={styles.filterOption} key={option}>
<FormControl>
<Checkbox
checked={filters[group.id].includes(option)}
onChange={() => toggleFilter(group.id, option)}
/>
<FormControl.Label>{option}</FormControl.Label>
</FormControl>
</div>
))}
</div>
{isExpandable ? (
<button
type="button"
className={styles.showMoreButton}
aria-expanded={expanded}
onClick={() => toggleGroupExpanded(group.id)}
>
{expanded
? "Show less"
: `Show ${group.options.length - FILTER_COLLAPSE_LIMIT} more`}
</button>
) : null}
</div>
</div>
))}
);
})}
{hasActiveFilters ? (
<div className={styles.filterActions}>
<Button
@@ -5,6 +5,7 @@ import React from "react";
import { Box, Breadcrumbs, Heading, Section, Text } from "@primer/react-brand";
import { PageShell } from "./PageShell";
import { LargeFooter } from "./LargeFooter";
import type { AwesomeCopilotPage } from "./navigation";
import type { SearchItem } from "./searchIndex";
import styles from "./styles/dotnet-upgrade.module.css";
@@ -105,6 +106,7 @@ export function DetailChassis({
currentPage={currentPage}
searchIndex={searchIndex}
contributorsTotal={contributorsTotal}
renderFooter={false}
>
<div className={styles.scrollHost} ref={contentScrollRef}>
<Box as="section" className={styles.hero}>
@@ -259,6 +261,7 @@ export function DetailChassis({
</div>
</Section>
</Box>
<LargeFooter />
</div>
<button
type="button"
@@ -86,6 +86,13 @@ const emptyFilters: FilterState = { pattern: [], extension: [], updated: [] };
const PAGE_SIZE = 6;
/**
* Long facets collapse behind a "Show N more" toggle, matching the prototype's
* extensions page. Real data produces far longer option lists than the
* prototype's hardcoded arrays, so every catalog needs this.
*/
const FILTER_COLLAPSE_LIMIT = 10;
/**
* The Instructions catalog, ported from the design prototype's
* `instructions.tsx`. The prototype's hardcoded array and filter options are
@@ -108,6 +115,12 @@ export function InstructionsCatalog({
const [currentPage, setCurrentPage] = useState(1);
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
const [filters, setFilters] = useState<FilterState>(emptyFilters);
const [expandedGroups, setExpandedGroups] = useState<
Record<FilterGroupId, boolean>
>({ pattern: false, extension: false, updated: false });
const toggleGroupExpanded = (groupId: FilterGroupId) => {
setExpandedGroups((prev) => ({ ...prev, [groupId]: !prev[groupId] }));
};
const filterGroups = useMemo<
{ id: FilterGroupId; label: string; options: string[] }[]
@@ -388,26 +401,47 @@ export function InstructionsCatalog({
!mobileFiltersOpen && styles.filterBodyCollapsed,
)}
>
{filterGroups.map((group) => (
<div className={styles.filterGroup} key={group.id}>
<Text as="h2" size="100" className={styles.filterHeading}>
{group.label}
</Text>
<div className={styles.filterOptions}>
{group.options.map((option) => (
<div className={styles.filterOption} key={option}>
<FormControl>
<Checkbox
checked={filters[group.id].includes(option)}
onChange={() => toggleFilter(group.id, option)}
/>
<FormControl.Label>{option}</FormControl.Label>
</FormControl>
</div>
))}
{filterGroups.map((group) => {
const isExpandable =
group.options.length > FILTER_COLLAPSE_LIMIT;
const expanded = expandedGroups[group.id];
const visibleOptions =
isExpandable && !expanded
? group.options.slice(0, FILTER_COLLAPSE_LIMIT)
: group.options;
return (
<div className={styles.filterGroup} key={group.id}>
<Text as="h2" size="100" className={styles.filterHeading}>
{group.label}
</Text>
<div className={styles.filterOptions}>
{visibleOptions.map((option) => (
<div className={styles.filterOption} key={option}>
<FormControl>
<Checkbox
checked={filters[group.id].includes(option)}
onChange={() => toggleFilter(group.id, option)}
/>
<FormControl.Label>{option}</FormControl.Label>
</FormControl>
</div>
))}
</div>
{isExpandable ? (
<button
type="button"
className={styles.showMoreButton}
aria-expanded={expanded}
onClick={() => toggleGroupExpanded(group.id)}
>
{expanded
? "Show less"
: `Show ${group.options.length - FILTER_COLLAPSE_LIMIT} more`}
</button>
) : null}
</div>
</div>
))}
);
})}
{hasActiveFilters ? (
<div className={styles.filterActions}>
<Button
+11 -1
View File
@@ -26,6 +26,13 @@ export type PageShellProps = {
contributorsTotal?: number;
searchAriaLabel?: string;
contributorsCurrent?: boolean;
/**
* Pages that scroll inside their own element (the detail/article chassis) must
* render the footer *inside* that scroll region, otherwise it sits outside the
* scrolling box and stays pinned over the content. Those pages opt out here
* and render `<LargeFooter />` themselves.
*/
renderFooter?: boolean;
children: ReactNode;
};
@@ -44,6 +51,7 @@ export function PageShell({
contributorsTotal = 0,
searchAriaLabel = "Search the library",
contributorsCurrent = false,
renderFooter = true,
children,
}: PageShellProps) {
return (
@@ -55,6 +63,7 @@ export function PageShell({
contributorsTotal={contributorsTotal}
searchAriaLabel={searchAriaLabel}
contributorsCurrent={contributorsCurrent}
renderFooter={renderFooter}
>
{children}
</PageShellBody>
@@ -74,6 +83,7 @@ function PageShellBody({
contributorsTotal = 0,
searchAriaLabel = "Search the library",
contributorsCurrent = false,
renderFooter = true,
children,
}: PageShellProps) {
const { colorMode } = useTheme();
@@ -121,7 +131,7 @@ function PageShellBody({
{children}
</main>
<LargeFooter />
{renderFooter ? <LargeFooter /> : null}
</Box>
);
}
+53 -19
View File
@@ -72,6 +72,13 @@ type FilterState = Record<FilterGroupId, string[]>;
const emptyFilters: FilterState = { source: [], category: [], size: [] };
/**
* Long facets collapse behind a "Show N more" toggle, matching the prototype's
* extensions page. Real data produces far longer option lists than the
* prototype's hardcoded arrays, so every catalog needs this.
*/
const FILTER_COLLAPSE_LIMIT = 10;
const sourceOf = (plugin: PluginItem) =>
plugin.external ? "External" : "Built-in";
@@ -120,6 +127,12 @@ export function PluginsCatalog({
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
const [filters, setFilters] = useState<FilterState>(emptyFilters);
const [expandedGroups, setExpandedGroups] = useState<
Record<FilterGroupId, boolean>
>({ source: false, category: false, size: false });
const toggleGroupExpanded = (groupId: FilterGroupId) => {
setExpandedGroups((prev) => ({ ...prev, [groupId]: !prev[groupId] }));
};
const filterGroups = useMemo(
(): { id: FilterGroupId; label: string; options: string[] }[] => [
@@ -226,26 +239,47 @@ export function PluginsCatalog({
!mobileFiltersOpen && styles.filterBodyCollapsed,
)}
>
{filterGroups.map((group) => (
<div className={styles.filterGroup} key={group.id}>
<Text as="h2" size="100" className={styles.filterHeading}>
{group.label}
</Text>
<div className={styles.filterOptions}>
{group.options.map((option) => (
<div className={styles.filterOption} key={option}>
<FormControl>
<Checkbox
checked={filters[group.id].includes(option)}
onChange={() => toggleFilter(group.id, option)}
/>
<FormControl.Label>{option}</FormControl.Label>
</FormControl>
</div>
))}
{filterGroups.map((group) => {
const isExpandable =
group.options.length > FILTER_COLLAPSE_LIMIT;
const expanded = expandedGroups[group.id];
const visibleOptions =
isExpandable && !expanded
? group.options.slice(0, FILTER_COLLAPSE_LIMIT)
: group.options;
return (
<div className={styles.filterGroup} key={group.id}>
<Text as="h2" size="100" className={styles.filterHeading}>
{group.label}
</Text>
<div className={styles.filterOptions}>
{visibleOptions.map((option) => (
<div className={styles.filterOption} key={option}>
<FormControl>
<Checkbox
checked={filters[group.id].includes(option)}
onChange={() => toggleFilter(group.id, option)}
/>
<FormControl.Label>{option}</FormControl.Label>
</FormControl>
</div>
))}
</div>
{isExpandable ? (
<button
type="button"
className={styles.showMoreButton}
aria-expanded={expanded}
onClick={() => toggleGroupExpanded(group.id)}
>
{expanded
? "Show less"
: `Show ${group.options.length - FILTER_COLLAPSE_LIMIT} more`}
</button>
) : null}
</div>
</div>
))}
);
})}
{hasActiveFilters ? (
<div className={styles.filterActions}>
<Button
+53 -19
View File
@@ -75,6 +75,13 @@ const resourceOf = (skill: SkillItem) =>
const PAGE_SIZE = 6;
/**
* Long facets collapse behind a "Show N more" toggle, matching the prototype's
* extensions page. Real data produces far longer option lists than the
* prototype's hardcoded arrays, so every catalog needs this.
*/
const FILTER_COLLAPSE_LIMIT = 10;
/**
* The Skills catalog, ported from the design prototype's `skills.tsx`. The
* prototype's hardcoded array is replaced by build-time data; the layout,
@@ -93,6 +100,12 @@ export function SkillsCatalog({
const [currentPage, setCurrentPage] = useState(1);
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
const [filters, setFilters] = useState<FilterState>(emptyFilters);
const [expandedGroups, setExpandedGroups] = useState<
Record<FilterGroupId, boolean>
>({ resources: false, files: false, updated: false });
const toggleGroupExpanded = (groupId: FilterGroupId) => {
setExpandedGroups((prev) => ({ ...prev, [groupId]: !prev[groupId] }));
};
const [copied, setCopied] = useState(false);
const copyTimer = React.useRef<number | undefined>(undefined);
@@ -195,26 +208,47 @@ export function SkillsCatalog({
!mobileFiltersOpen && styles.filterBodyCollapsed,
)}
>
{filterGroups.map((group) => (
<div className={styles.filterGroup} key={group.id}>
<Text as="h2" size="100" className={styles.filterHeading}>
{group.label}
</Text>
<div className={styles.filterOptions}>
{group.options.map((option) => (
<div className={styles.filterOption} key={option}>
<FormControl>
<Checkbox
checked={filters[group.id].includes(option)}
onChange={() => toggleFilter(group.id, option)}
/>
<FormControl.Label>{option}</FormControl.Label>
</FormControl>
</div>
))}
{filterGroups.map((group) => {
const isExpandable =
group.options.length > FILTER_COLLAPSE_LIMIT;
const expanded = expandedGroups[group.id];
const visibleOptions =
isExpandable && !expanded
? group.options.slice(0, FILTER_COLLAPSE_LIMIT)
: group.options;
return (
<div className={styles.filterGroup} key={group.id}>
<Text as="h2" size="100" className={styles.filterHeading}>
{group.label}
</Text>
<div className={styles.filterOptions}>
{visibleOptions.map((option) => (
<div className={styles.filterOption} key={option}>
<FormControl>
<Checkbox
checked={filters[group.id].includes(option)}
onChange={() => toggleFilter(group.id, option)}
/>
<FormControl.Label>{option}</FormControl.Label>
</FormControl>
</div>
))}
</div>
{isExpandable ? (
<button
type="button"
className={styles.showMoreButton}
aria-expanded={expanded}
onClick={() => toggleGroupExpanded(group.id)}
>
{expanded
? "Show less"
: `Show ${group.options.length - FILTER_COLLAPSE_LIMIT} more`}
</button>
) : null}
</div>
</div>
))}
);
})}
{hasActiveFilters ? (
<div className={styles.filterActions}>
<Button
@@ -683,6 +683,35 @@
display: flex;
flex-direction: column;
gap: var(--base-size-4);
max-block-size: 480px;
overflow-y: auto;
padding-inline-end: var(--base-size-4);
scrollbar-width: thin;
}
.showMoreButton {
align-self: flex-start;
margin-block-start: var(--base-size-8);
margin-inline-start: var(--base-size-12);
padding: 0;
border: 0;
background: none;
color: var(--brand-color-text-default);
font-family: var(--brand-fontStack-monospace);
font-size: var(--brand-text-size-100);
text-decoration: underline;
text-underline-offset: 0.25em;
cursor: pointer;
}
.showMoreButton:hover {
color: var(--brand-color-text-muted);
}
.showMoreButton:focus-visible {
outline: var(--brand-borderWidth-thin) solid var(--brand-color-focus);
outline-offset: var(--base-size-4);
border-radius: var(--brand-borderRadius-small);
}
.filterOption {
@@ -669,6 +669,35 @@
display: flex;
flex-direction: column;
gap: var(--base-size-4);
max-block-size: 480px;
overflow-y: auto;
padding-inline-end: var(--base-size-4);
scrollbar-width: thin;
}
.showMoreButton {
align-self: flex-start;
margin-block-start: var(--base-size-8);
margin-inline-start: var(--base-size-12);
padding: 0;
border: 0;
background: none;
color: var(--brand-color-text-default);
font-family: var(--brand-fontStack-monospace);
font-size: var(--brand-text-size-100);
text-decoration: underline;
text-underline-offset: 0.25em;
cursor: pointer;
}
.showMoreButton:hover {
color: var(--brand-color-text-muted);
}
.showMoreButton:focus-visible {
outline: var(--brand-borderWidth-thin) solid var(--brand-color-focus);
outline-offset: var(--base-size-4);
border-radius: var(--brand-borderRadius-small);
}
.filterOption {
@@ -767,6 +767,35 @@
display: flex;
flex-direction: column;
gap: var(--base-size-4);
max-block-size: 480px;
overflow-y: auto;
padding-inline-end: var(--base-size-4);
scrollbar-width: thin;
}
.showMoreButton {
align-self: flex-start;
margin-block-start: var(--base-size-8);
margin-inline-start: var(--base-size-12);
padding: 0;
border: 0;
background: none;
color: var(--brand-color-text-default);
font-family: var(--brand-fontStack-monospace);
font-size: var(--brand-text-size-100);
text-decoration: underline;
text-underline-offset: 0.25em;
cursor: pointer;
}
.showMoreButton:hover {
color: var(--brand-color-text-muted);
}
.showMoreButton:focus-visible {
outline: var(--brand-borderWidth-thin) solid var(--brand-color-focus);
outline-offset: var(--base-size-4);
border-radius: var(--brand-borderRadius-small);
}
.filterOption {
@@ -676,6 +676,35 @@
display: flex;
flex-direction: column;
gap: var(--base-size-4);
max-block-size: 480px;
overflow-y: auto;
padding-inline-end: var(--base-size-4);
scrollbar-width: thin;
}
.showMoreButton {
align-self: flex-start;
margin-block-start: var(--base-size-8);
margin-inline-start: var(--base-size-12);
padding: 0;
border: 0;
background: none;
color: var(--brand-color-text-default);
font-family: var(--brand-fontStack-monospace);
font-size: var(--brand-text-size-100);
text-decoration: underline;
text-underline-offset: 0.25em;
cursor: pointer;
}
.showMoreButton:hover {
color: var(--brand-color-text-muted);
}
.showMoreButton:focus-visible {
outline: var(--brand-borderWidth-thin) solid var(--brand-color-focus);
outline-offset: var(--base-size-4);
border-radius: var(--brand-borderRadius-small);
}
.filterOption {