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
191 changes: 191 additions & 0 deletions src/components/LibraryCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import * as React from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBuildingColumns } from "@fortawesome/free-solid-svg-icons";

/**
* Size of the logo tile and the minimum height of a card's content, in
* pixels. 44px is the minimum comfortable touch target size.
*/
const TILE_SIZE = 44;

/**
* Marks the card's main interactive element. Its ::after pseudo-element
* stretches over the whole card, so a click anywhere on the card activates
* it without adding the card itself to the tab order.
*/
const ACTION_ATTRIBUTE = "data-library-card-action";

/** Props the card's interactive element must spread onto itself. */
export type LibraryCardActionProps = {
[ACTION_ATTRIBUTE]: "";
"aria-describedby"?: string;
};

interface LibraryCardProps {
logoUrl?: string;
description?: string;
/**
* Renders the card's interactive element (a link or button carrying the
* library title), which must spread `actionProps` onto itself.
*/
children: (actionProps: LibraryCardActionProps) => React.ReactNode;
/**
* Rendered below the card's main row and stacked over the stretched click
* target, so its controls stay clickable.
*/
footer?: React.ReactNode;
}

/** Shown in the logo tile when a library has no logo or it fails to load. */
const DefaultLibraryLogo: React.FC = () => (
<FontAwesomeIcon icon={faBuildingColumns} />
);

/*
* The frame around the logo or default logo. It shows as a blank tile while
* a logo loads.
*/
const tileStyle = {
height: TILE_SIZE,
width: TILE_SIZE,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
p: "2px",
border: "1px solid",
borderColor: "ui.gray.light",
borderRadius: 2,
backgroundColor: "ui.white"
} as const;

/**
* One library in a list: a clickable card with a logo tile, the title, and
* an optional description. The description is attached to the interactive
* element with aria-describedby, so screen readers announce it with the
* title. The tile shows a generic library icon when there is no logo or the
* logo fails to load, and stays blank while the logo loads.
*/
const LibraryCard: React.FC<LibraryCardProps> = ({
logoUrl,
description,
children,
footer
}) => {
const descriptionId = React.useId();
const [failedLogoUrl, setFailedLogoUrl] = React.useState<string>();
const showLogo = logoUrl && logoUrl !== failedLogoUrl;
const action = `[${ACTION_ATTRIBUTE}]`;

return (
<div
sx={{
position: "relative",
border: "solid",
borderColor: "ui.gray.medium",
borderRadius: "card",
backgroundColor: "ui.white",
boxShadow: "card",
px: 3,
py: 2,
transition: "box-shadow 150ms ease, border-color 150ms ease",
"@media (prefers-reduced-motion: reduce)": { transition: "none" },
"&:hover": {
boxShadow: "cardHover",
borderColor: "ui.gray.dark"
},
// A separate rule so browsers without :has() still get the hover
// above.
"&:hover:has([aria-disabled='true'])": {
boxShadow: "card",
borderColor: "ui.gray.medium"
},
[action]: {
color: "ui.link.primary",
fontWeight: "medium",
textDecoration: "none",
"&::after": {
content: '""',
position: "absolute",
inset: 0,
borderRadius: "card"
},
"&:focus-visible": { outline: "none" },
"&:focus-visible::after": {
outline: "3px solid",
outlineColor: "ui.blue.focus",
outlineOffset: "2px"
}
}
}}
>
<div
sx={{
display: "flex",
alignItems: "center",
gap: 3,
minHeight: TILE_SIZE
}}
>
<div sx={tileStyle}>
{showLogo ? (
<img
src={logoUrl}
alt=""
loading="lazy"
decoding="async"
onError={() => setFailedLogoUrl(logoUrl)}
sx={{
height: "100%",
width: "100%",
objectFit: "contain"
}}
/>
) : (
<span
aria-hidden="true"
data-testid="default-library-logo"
sx={{ display: "flex", color: "ui.gray.dark" }}
>
<DefaultLibraryLogo />
</span>
)}
</div>
<div
sx={{
display: "flex",
flexDirection: "column",
flex: 1,
minWidth: 0
}}
>
<div>
{children({
[ACTION_ATTRIBUTE]: "",
...(description ? { "aria-describedby": descriptionId } : {})
})}
</div>
{description && (
<div
id={descriptionId}
sx={{
fontSize: "-1",
color: "ui.gray.extraDark",
// Clamp long registry descriptions to two lines.
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden"
}}
>
{description}
</div>
)}
</div>
</div>
{footer && <div sx={{ position: "relative", zIndex: 1 }}>{footer}</div>}
</div>
);
};

export default LibraryCard;
16 changes: 15 additions & 1 deletion src/components/LibraryFilterList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,21 @@ const LibraryFilterList: React.FC<LibraryFilterListProps> = ({
{filterQuery && resultCount === 0 && (
<p sx={{ pl: 2 }}>{NO_MATCH_MESSAGE}</p>
)}
<ul id={resultsListId}>
{/* eslint-disable-next-line jsx-a11y/no-redundant-roles */}
<ul
id={resultsListId}
// Keeps list semantics in Safari, which drops them from lists
// styled with listStyle: none.
role="list"
sx={{
listStyle: "none",
pl: 0,
maxWidth: "40rem",
display: "flex",
flexDirection: "column",
gap: 2
}}
>
{displayed.map(({ item, matchIndices }) => (
<li key={item.slug}>
{renderItem({
Expand Down
8 changes: 6 additions & 2 deletions src/components/LibraryHomeLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import { useRouter } from "next/router";
*/
export const FOCUS_PREFETCH_DEBOUNCE_MS = 300;

export interface LibraryHomeLinkProps {
export interface LibraryHomeLinkProps extends Omit<
React.AnchorHTMLAttributes<HTMLAnchorElement>,
"href" | "title" | "onFocus" | "onBlur"
> {
slug: string;
title?: string;
}
Expand All @@ -33,7 +36,7 @@ export interface LibraryHomeLinkProps {
*/
const LibraryHomeLink: React.FC<
React.PropsWithChildren<LibraryHomeLinkProps>
> = ({ slug, title, children }) => {
> = ({ slug, title, children, ...rest }) => {
const router = useRouter();
const href = `/${slug}`;
const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(
Expand All @@ -54,6 +57,7 @@ const LibraryHomeLink: React.FC<

return (
<Link
{...rest}
href={href}
prefetch={false}
onFocus={prefetchOnFocus}
Expand Down
20 changes: 17 additions & 3 deletions src/components/MultiLibraryHome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useAppConfig } from "components/context/AppConfigContext";
import theme from "theme/theme";
import LibraryHomeLink from "./LibraryHomeLink";
import LibraryFilterList from "components/LibraryFilterList";
import LibraryCard from "components/LibraryCard";
import { fetchLibraries } from "dataflow/fetchLibraries";
import type { ClientLibrary, LibrariesResponse } from "pages/api/libraries";
import { useTranslation } from "next-i18next/pages";
Expand Down Expand Up @@ -46,6 +47,7 @@ const MultiLibraryHome: React.FC = () => {
return titleA.localeCompare(titleB);
}
);
const librariesBySlug = new Map(sorted.map(lib => [lib.slug, lib]));

return (
<ThemeUIProvider theme={theme}>
Expand All @@ -67,9 +69,21 @@ const MultiLibraryHome: React.FC = () => {
label: lib.title || lib.slug
}))}
resultsListId="library-filter-results"
renderItem={({ slug, highlighted }) => (
<LibraryHomeLink slug={slug}>{highlighted}</LibraryHomeLink>
)}
renderItem={({ slug, highlighted }) => {
const library = librariesBySlug.get(slug);
return (
<LibraryCard
logoUrl={library?.logoUrl}
description={library?.description}
>
{actionProps => (
<LibraryHomeLink slug={slug} {...actionProps}>
{highlighted}
</LibraryHomeLink>
)}
</LibraryCard>
);
}}
/>
</Themed.root>
</ThemeUIProvider>
Expand Down
Loading
Loading