Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
26 changes: 22 additions & 4 deletions website/src/components/brand/AgentsCatalog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ import { useEffect, useMemo, useRef, useState } from "react";

import { PageShell } from "./PageShell";
import { CatalogSortControl, CATALOG_SORT_OPTIONS } from "./CatalogSortControl";
import {
CatalogResourceSizeControl,
CATALOG_RESOURCE_SIZE_OPTIONS,
DEFAULT_CATALOG_RESOURCE_SIZE,
type CatalogResourceSize,
} from "./CatalogResourceSizeControl";
import { daysSince, toggleValue, updatedBuckets, updatedBucketOf } from "./catalogFilters";
import { pageHref } from "./pageHref";
import { downloadFile } from "./resourceActions";
Expand Down Expand Up @@ -91,8 +97,6 @@ const emptyFilters: FilterState = { model: [], tools: [], updated: [] };

type SortMode = "az" | "newest";

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
Expand All @@ -119,6 +123,9 @@ export function AgentsCatalog({
}) {
const { colorMode } = useTheme();
const [sortMode, setSortMode] = useState<SortMode>("az");
const [pageSize, setPageSize] = useState<CatalogResourceSize>(
DEFAULT_CATALOG_RESOURCE_SIZE,
);
const [currentPage, setCurrentPage] = useState(1);
const previousPage = useRef(currentPage);
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
Expand Down Expand Up @@ -189,9 +196,9 @@ export function AgentsCatalog({
const activeFilterCount =
filters.model.length + filters.tools.length + filters.updated.length;
const hasActiveFilters = activeFilterCount > 0;
const pageCount = Math.max(1, Math.ceil(sortedAgents.length / PAGE_SIZE));
const pageCount = Math.max(1, Math.ceil(sortedAgents.length / pageSize));
const page = Math.min(currentPage, pageCount);
const visibleAgents = sortedAgents.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
const visibleAgents = sortedAgents.slice((page - 1) * pageSize, page * pageSize);

useEffect(() => {
if (previousPage.current === currentPage) return;
Expand Down Expand Up @@ -368,6 +375,17 @@ export function AgentsCatalog({
setCurrentPage(1);
}}
/>
<div className={styles.toolbarDivider} aria-hidden="true" />
<CatalogResourceSizeControl
ariaLabel="Agents per page"
value={pageSize}
options={CATALOG_RESOURCE_SIZE_OPTIONS}
styles={styles}
onChange={(value) => {
setPageSize(value);
setCurrentPage(1);
}}
/>
</Box>
<Box className={styles.gridFrame} data-mode={colorMode}>
<Box className={styles.gridContent}>
Expand Down
175 changes: 175 additions & 0 deletions website/src/components/brand/CatalogResourceSizeControl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { CheckIcon, ChevronDownIcon } from "@primer/octicons-react";
import { clsx } from "clsx";
import {
useEffect,
useRef,
useState,
type KeyboardEvent,
type SyntheticEvent,
} from "react";

export type CatalogResourceSizeOption<T extends number> = {
value: T;
label: string;
};

export const CATALOG_RESOURCE_SIZE_OPTIONS = [
{ value: 6, label: "6" },
{ value: 25, label: "25" },
{ value: 50, label: "50" },
{ value: 100, label: "100" },
] as const satisfies readonly CatalogResourceSizeOption<number>[];

/** A page size the catalogs offer: 6, 25, 50, or 100 items. */
export type CatalogResourceSize =
(typeof CATALOG_RESOURCE_SIZE_OPTIONS)[number]["value"];

/** How many items a catalog page shows until the reader picks a size. */
export const DEFAULT_CATALOG_RESOURCE_SIZE: CatalogResourceSize = 6;

type CatalogResourceSizeControlProps<T extends number> = {
ariaLabel: string;
onChange: (value: T) => void;
options: readonly CatalogResourceSizeOption<T>[];
/** The catalog's CSS module. The menu reuses the sort menu's classes so the
* two toolbar menus look the same. */
styles: Record<string, string | undefined>;
value: T;
};

/**
* The toolbar's page-size menu. `<details>` only gives a disclosure, so this
* adds the menu button keyboard pattern on top: opening moves focus to the
* selected size, arrow keys and Home/End move between sizes, Escape closes the
* menu and returns focus to the trigger, and Tab or a click outside closes it.
*/
export function CatalogResourceSizeControl<T extends number>({
ariaLabel,
onChange,
options,
styles,
value,
}: CatalogResourceSizeControlProps<T>) {
const menuRef = useRef<HTMLDetailsElement>(null);
const triggerRef = useRef<HTMLElement>(null);
const [open, setOpen] = useState(false);
const selectedIndex = options.findIndex((option) => option.value === value);
const selectedLabel = options[selectedIndex]?.label ?? String(value);

const menuItems = () =>
Array.from(
menuRef.current?.querySelectorAll<HTMLButtonElement>(
'[role="menuitemradio"]',
) ?? [],
);

const closeMenu = (restoreFocus: boolean) => {
if (menuRef.current) menuRef.current.open = false;
if (restoreFocus) triggerRef.current?.focus();
};

useEffect(() => {
if (!open) return;
const handlePointerDown = (event: PointerEvent) => {
const menu = menuRef.current;
if (menu && !menu.contains(event.target as Node)) menu.open = false;
};
document.addEventListener("pointerdown", handlePointerDown);
return () => document.removeEventListener("pointerdown", handlePointerDown);
}, [open]);

const handleToggle = (event: SyntheticEvent<HTMLDetailsElement>) => {
const isOpen = event.currentTarget.open;
setOpen(isOpen);
if (isOpen) menuItems()[Math.max(selectedIndex, 0)]?.focus();
};

const handleKeyDown = (event: KeyboardEvent<HTMLDetailsElement>) => {
const menu = event.currentTarget;
if (!menu.open) {
// Arrow keys open the menu from the trigger, as they do on a select.
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
menu.open = true;
}
return;
}

const items = menuItems();
const current = items.indexOf(document.activeElement as HTMLButtonElement);
let next: number;
switch (event.key) {
case "Escape":
event.preventDefault();
closeMenu(true);
return;
case "Tab":
// Let focus move on to the next control; just close behind it.
closeMenu(false);
return;
case "ArrowDown":
next = (current + 1) % items.length;
break;
case "ArrowUp":
next = current <= 0 ? items.length - 1 : current - 1;
break;
case "Home":
next = 0;
break;
case "End":
next = items.length - 1;
break;
default:
return;
}
event.preventDefault();
items[next]?.focus();
};

return (
<div className={styles.sortControl}>
<span className={styles.sortLabel}>Resource page size:</span>
<details
ref={menuRef}
className={styles.sortMenu}
onToggle={handleToggle}
onKeyDown={handleKeyDown}
>
<summary
ref={triggerRef}
className={styles.sortTrigger}
aria-haspopup="menu"
aria-label={`Resource page size: ${selectedLabel} (${ariaLabel})`}
>
{selectedLabel}
<ChevronDownIcon size={16} className={styles.sortChevron} />
</summary>
<div className={styles.sortOverlay} role="menu" aria-label={ariaLabel}>
{options.map((option) => (
<button
key={option.value}
type="button"
role="menuitemradio"
aria-checked={value === option.value}
className={clsx(
styles.sortOption,
value === option.value && styles.sortOptionActive,
)}
onClick={() => {
onChange(option.value);
closeMenu(true);
}}
>
{option.label}
<CheckIcon
size={16}
className={styles.sortOptionCheck}
aria-hidden={value !== option.value}
/>
</button>
))}
</div>
</details>
</div>
);
}
28 changes: 23 additions & 5 deletions website/src/components/brand/ExtensionsCatalog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ import {
import { ExtensionsIcon } from "./ExtensionsIcon";
import { PageShell } from "./PageShell";
import { CatalogSortControl, CATALOG_SORT_OPTIONS } from "./CatalogSortControl";
import {
CatalogResourceSizeControl,
CATALOG_RESOURCE_SIZE_OPTIONS,
DEFAULT_CATALOG_RESOURCE_SIZE,
type CatalogResourceSize,
} from "./CatalogResourceSizeControl";
import { TypingText } from "./TypingText";
import { httpUrl } from "./resourceActions";
import {
Expand Down Expand Up @@ -84,8 +90,6 @@ type FilterState = Record<FilterGroupId, string[]>;

const emptyFilters: FilterState = { author: [], updated: [], keyword: [] };

const PAGE_SIZE = 6;

type SortMode = "az" | "newest";

function CopyInstallButton({
Expand Down Expand Up @@ -148,6 +152,9 @@ export function ExtensionsCatalog({
}) {
const { colorMode } = useTheme();
const [sortMode, setSortMode] = useState<SortMode>("az");
const [pageSize, setPageSize] = useState<CatalogResourceSize>(
DEFAULT_CATALOG_RESOURCE_SIZE,
);
const [currentPage, setCurrentPage] = useState(1);
const previousPage = React.useRef(currentPage);
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
Expand Down Expand Up @@ -234,11 +241,11 @@ export function ExtensionsCatalog({
const activeFilterCount =
filters.author.length + filters.updated.length + filters.keyword.length;
const hasActiveFilters = activeFilterCount > 0;
const pageCount = Math.max(1, Math.ceil(sortedExtensions.length / PAGE_SIZE));
const pageCount = Math.max(1, Math.ceil(sortedExtensions.length / pageSize));
const page = Math.min(currentPage, pageCount);
const visibleExtensions = sortedExtensions.slice(
(page - 1) * PAGE_SIZE,
page * PAGE_SIZE,
(page - 1) * pageSize,
page * pageSize,
);

React.useEffect(() => {
Expand Down Expand Up @@ -375,6 +382,17 @@ export function ExtensionsCatalog({
setCurrentPage(1);
}}
/>
<div className={styles.toolbarDivider} aria-hidden="true" />
<CatalogResourceSizeControl
ariaLabel="Extensions per page"
value={pageSize}
options={CATALOG_RESOURCE_SIZE_OPTIONS}
styles={styles}
onChange={(value) => {
setPageSize(value);
setCurrentPage(1);
}}
/>
</Box>
<Box className={styles.gridFrame} data-mode={colorMode}>
<Box className={styles.gridContent}>
Expand Down
Loading
Loading