diff --git a/src/components/LibraryCard.tsx b/src/components/LibraryCard.tsx new file mode 100644 index 00000000..cb313f23 --- /dev/null +++ b/src/components/LibraryCard.tsx @@ -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 = () => ( + +); + +/* + * 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 = ({ + logoUrl, + description, + children, + footer +}) => { + const descriptionId = React.useId(); + const [failedLogoUrl, setFailedLogoUrl] = React.useState(); + const showLogo = logoUrl && logoUrl !== failedLogoUrl; + const action = `[${ACTION_ATTRIBUTE}]`; + + return ( +
+
+
+ {showLogo ? ( + setFailedLogoUrl(logoUrl)} + sx={{ + height: "100%", + width: "100%", + objectFit: "contain" + }} + /> + ) : ( + + )} +
+
+
+ {children({ + [ACTION_ATTRIBUTE]: "", + ...(description ? { "aria-describedby": descriptionId } : {}) + })} +
+ {description && ( +
+ {description} +
+ )} +
+
+ {footer &&
{footer}
} +
+ ); +}; + +export default LibraryCard; diff --git a/src/components/LibraryFilterList.tsx b/src/components/LibraryFilterList.tsx index 478a35b6..3575321c 100644 --- a/src/components/LibraryFilterList.tsx +++ b/src/components/LibraryFilterList.tsx @@ -104,7 +104,21 @@ const LibraryFilterList: React.FC = ({ {filterQuery && resultCount === 0 && (

{NO_MATCH_MESSAGE}

)} -