diff --git a/frontends/main/src/app-pages/HomePage/HomePage.test.tsx b/frontends/main/src/app-pages/HomePage/HomePage.test.tsx index 6938d2be05..79d112e8ed 100644 --- a/frontends/main/src/app-pages/HomePage/HomePage.test.tsx +++ b/frontends/main/src/app-pages/HomePage/HomePage.test.tsx @@ -57,7 +57,7 @@ const setupAPIs = () => { limit: 6, sortby: "-news_date", }), - {}, + newsEvents.newsItems({ count: 0 }), ) setMockResponse.get( urls.newsEvents.list({ @@ -65,7 +65,7 @@ const setupAPIs = () => { limit: 5, sortby: "event_date", }), - {}, + newsEvents.eventItems({ count: 0 }), ) setMockResponse.get(urls.topics.list({ is_toplevel: true }), { diff --git a/frontends/main/src/app-pages/HomePage/NewsEventsSection.tsx b/frontends/main/src/app-pages/HomePage/NewsEventsSection.tsx index 9c6923bd8a..2a5939b82d 100644 --- a/frontends/main/src/app-pages/HomePage/NewsEventsSection.tsx +++ b/frontends/main/src/app-pages/HomePage/NewsEventsSection.tsx @@ -15,6 +15,7 @@ import { import type { NewsFeedItem, EventFeedItem } from "api/v0" import { formatDate } from "ol-utilities" import { RiArrowRightSLine } from "@remixicon/react" +import Link from "next/link" const Section = styled.section` background: ${theme.custom.colors.white}; @@ -111,10 +112,7 @@ const EventCard = styled(Card)` align-self: stretch; justify-content: space-between; overflow: visible; - - > a { - padding: 16px; - } + padding: 16px; ` const EventDate = styled.div` @@ -190,7 +188,7 @@ const Story: React.FC<{ item: NewsFeedItem; mobile: boolean }> = ({ mobile, }) => { return ( - + {item.image.url ? ( ) : null} @@ -221,31 +219,32 @@ const NewsEventsSection: React.FC = () => { return null } - const stories = news!.results?.slice(0, 6) || [] + const stories = news.results.slice(0, 6) - const EventCards = - events!.results?.map((item) => ( - - - - - {formatDate( - (item as EventFeedItem).event_details?.event_datetime, - "D", - )} - - - {formatDate( - (item as EventFeedItem).event_details?.event_datetime, - "MMM", - )} - - + const EventCards = events.results.map((item) => ( + + + + + {formatDate( + (item as EventFeedItem).event_details?.event_datetime, + "D", + )} + + + {formatDate( + (item as EventFeedItem).event_details?.event_datetime, + "MMM", + )} + + + {item.title} - - - - )) || [] + + + + + )) return (
diff --git a/frontends/main/src/page-components/UserListCard/UserListCardCondensed.tsx b/frontends/main/src/page-components/UserListCard/UserListCardCondensed.tsx index 1689b5bd71..976e13e696 100644 --- a/frontends/main/src/page-components/UserListCard/UserListCardCondensed.tsx +++ b/frontends/main/src/page-components/UserListCard/UserListCardCondensed.tsx @@ -3,6 +3,7 @@ import { UserList } from "api" import { pluralize } from "ol-utilities" import { RiListCheck3 } from "@remixicon/react" import { ListCardCondensed, styled, theme, Typography } from "ol-components" +import Link from "next/link" const StyledCard = styled(ListCardCondensed)({ display: "flex", @@ -37,24 +38,29 @@ const IconContainer = styled.div(({ theme }) => ({ }, })) -type UserListCardCondensedProps = { - userList: U - href?: string +type UserListCardCondensedProps = { + userList: UserList + href: string className?: string } -const UserListCardCondensed = ({ +const UserListCardCondensed = ({ userList, href, className, -}: UserListCardCondensedProps) => { +}: UserListCardCondensedProps) => { return ( - - {userList.title} - + + + {userList.title} + + {userList.item_count} {pluralize("item", userList.item_count)} diff --git a/frontends/ol-components/src/components/Card/Card.test.tsx b/frontends/ol-components/src/components/Card/Card.test.tsx index a087bedb6d..d0b9827580 100644 --- a/frontends/ol-components/src/components/Card/Card.test.tsx +++ b/frontends/ol-components/src/components/Card/Card.test.tsx @@ -1,8 +1,10 @@ -import { render } from "@testing-library/react" +import { render, screen } from "@testing-library/react" +import user from "@testing-library/user-event" import { Card } from "./Card" import React from "react" import { getOriginalSrc } from "ol-test-utilities" import invariant from "tiny-invariant" +import { ThemeProvider } from "../ThemeProvider/ThemeProvider" describe("Card", () => { test("has class MitCard-root on root element", () => { @@ -14,6 +16,7 @@ describe("Card", () => { Footer Actions , + { wrapper: ThemeProvider }, ) const card = container.firstChild as HTMLElement const title = card.querySelector(".MitCard-title") @@ -37,4 +40,97 @@ describe("Card", () => { expect(footer).toHaveTextContent("Footer") expect(actions).toHaveTextContent("Actions") }) + + test.each([ + { forwardClicksToLink: true, finalHref: "#woof" }, + { forwardClicksToLink: false, finalHref: "" }, + ])( + "The whole card is clickable as a link if forwardClicksToLink ($forwardClicksToLink)", + async ({ forwardClicksToLink, finalHref }) => { + const href = "#woof" + render( + + Title + + Info + Footer + Actions + , + { wrapper: ThemeProvider }, + ) + const card = document.querySelector(".MitCard-root") + invariant(card instanceof HTMLDivElement) // Sanity: Chceck it's not an anchor + + await user.click(card) + expect(window.location.hash).toBe(finalHref) + }, + ) + + test.each([ + { forwardClicksToLink: true, finalHref: "#meow" }, + { forwardClicksToLink: false, finalHref: "" }, + ])( + "The whole card is clickable as a link when using Content when forwardClicksToLink ($forwardClicksToLink), except buttons and links", + async ({ finalHref, forwardClicksToLink }) => { + const href = "#meow" + const onClick = jest.fn() + render( + + +
Hello!
+
+ +
+ Link +
+
, + { wrapper: ThemeProvider }, + ) + const button = screen.getByRole("button", { name: "Button" }) + await user.click(button) + expect(onClick).toHaveBeenCalled() + expect(window.location.hash).toBe("") + + // outermost wrapper is not actually clickable + const card = document.querySelector(".MitCard-root") + invariant(card instanceof HTMLDivElement) // Sanity: Chceck it's not an anchor + + await user.click(card) + expect(window.location.hash).toBe(finalHref) + }, + ) + + test("Clicks on interactive elements are not forwarded", async () => { + const btnOnClick = jest.fn() + const divOnClick = jest.fn() + render( + + Title + + Info + + + Link Two + {/* + eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions + */} +
+ Interactive Div +
+
+
, + { wrapper: ThemeProvider }, + ) + const button = screen.getByRole("button", { name: "Button" }) + const link = screen.getByRole("link", { name: "Link Two" }) + const div = screen.getByText("Interactive Div") + await user.click(button) + expect(btnOnClick).toHaveBeenCalled() + expect(window.location.hash).toBe("") + await user.click(link) + expect(window.location.hash).toBe("#two") + await user.click(div) + expect(divOnClick).toHaveBeenCalled() + expect(window.location.hash).toBe("#two") + }) }) diff --git a/frontends/ol-components/src/components/Card/Card.tsx b/frontends/ol-components/src/components/Card/Card.tsx index a04dc0302a..c4ed39316e 100644 --- a/frontends/ol-components/src/components/Card/Card.tsx +++ b/frontends/ol-components/src/components/Card/Card.tsx @@ -4,6 +4,8 @@ import React, { Children, isValidElement, CSSProperties, + useCallback, + AriaAttributes, } from "react" import styled from "@emotion/styled" import { theme } from "../ThemeProvider/ThemeProvider" @@ -13,52 +15,59 @@ import { default as NextImage, ImageProps as NextImageProps } from "next/image" export type Size = "small" | "medium" -/* - *The relative positioned wrapper allows the action buttons to live adjacent to the - * Link container in the DOM structure. They cannot be a descendent of it as - * buttons inside anchors are not valid HTML. +type LinkableProps = { + href?: string + children?: ReactNode + className?: string +} +/** + * Render a NextJS link if href is provided, otherwise a span. + * Does not scroll if the href is a query string. */ -export const Wrapper = styled.div<{ size?: Size }>` - position: relative; - ${({ size }) => { - let width - if (!size) return "" - if (size === "medium") width = 300 - if (size === "small") width = 192 - return ` - min-width: ${width}px; - max-width: ${width}px; - ` - }} -` - -export const containerStyles = ` - border-radius: 8px; - border: 1px solid ${theme.custom.colors.lightGray2}; - background: ${theme.custom.colors.white}; - overflow: hidden; -` - -const LinkContainer = styled(Link)` - ${containerStyles} - display: block; - position: relative; - - :hover { - text-decoration: none; - border-color: ${theme.custom.colors.silverGrayLight}; - box-shadow: - 0 2px 4px 0 rgb(37 38 43 / 10%), - 0 2px 4px 0 rgb(37 38 43 / 10%); - cursor: pointer; +export const Linkable: React.FC = ({ + href, + children, + className, +}) => { + if (href) { + return ( + + {children} + + ) } -` + return {children} +} -const Container = styled.div` - ${containerStyles} - display: block; - position: relative; -` +export const BaseContainer = styled.div<{ display?: CSSProperties["display"] }>( + ({ theme, onClick, display = "block" }) => [ + { + borderRadius: "8px", + border: `1px solid ${theme.custom.colors.lightGray2}`, + background: theme.custom.colors.white, + display, + overflow: "hidden", // to clip image so they match border radius + }, + onClick && { + "&:hover": { + borderColor: theme.custom.colors.silverGrayLight, + boxShadow: + "0 2px 4px 0 rgb(37 38 43 / 10%), 0 2px 4px 0 rgb(37 38 43 / 10%)", + cursor: "pointer", + }, + }, + ], +) +const CONTAINER_WIDTHS: Record = { + small: 192, + medium: 300, +} +const Container = styled(BaseContainer)<{ size?: Size }>(({ size }) => [ + size && { + minWidth: CONTAINER_WIDTHS[size], + maxWidth: CONTAINER_WIDTHS[size], + }, +]) const Content = () => <> @@ -83,7 +92,7 @@ const Info = styled.div<{ size?: Size }>` margin-bottom: ${({ size }) => (size === "small" ? 4 : 8)}px; ` -const Title = styled.span<{ lines?: number; size?: Size }>` +const Title = styled(Linkable)<{ lines?: number; size?: Size }>` text-overflow: ellipsis; height: ${({ lines, size }) => { const lineHeightPx = size === "small" ? 18 : 20 @@ -130,17 +139,76 @@ const Bottom = styled.div` const Actions = styled.div` display: flex; gap: 8px; - position: absolute; - bottom: 16px; - right: 16px; ` +/** + * Click the child anchor element if the click event target is not the anchor itself. + * + * Allows making a whole region clickable as a link, even if the link is not the + * direct target of the click event. + */ +export const useClickChildHref = ( + href?: string, + onClick?: React.MouseEventHandler, +): React.MouseEventHandler => { + return useCallback( + (e) => { + onClick?.(e) + if (!e.currentTarget.contains(e.target as Node)) { + // This happens if click target is a child in React tree but not DOM tree + // This can happen with portals. + // In such cases, data-card-actions won't be a parent of the target. + return + } + const anchor = e.currentTarget.querySelector( + `a[href="${href}"]`, + ) + const target = e.target as HTMLElement + if (!anchor || target.closest("a, button, [data-card-action]")) return + if (e.metaKey || e.ctrlKey) { + /** + * Enables ctrl+click to open card's link in new tab. + * Without this, ctrl+click only works on the anchor itself. + */ + const opts: PointerEventInit = { + bubbles: false, + metaKey: e.metaKey, + ctrlKey: e.ctrlKey, + } + anchor.dispatchEvent(new PointerEvent("click", opts)) + } else { + anchor.click() + } + }, + [href, onClick], + ) +} + type CardProps = { children: ReactNode[] | ReactNode className?: string size?: Size + /** + * If provided, the card will render its title as a link. + * + * Clicks on the entire card can be forwarded to the link via `forwardClicksToLink`. + */ href?: string -} + /** + * Defaults to `false`. If `true`, clicking the whole card will click the + * href link as well. + * + * NOTES: + * - If using Card.Content to customize, you must ensure the content includes + * an anchor with the card's href. + * - Clicks will NOT be forwarded if it is (or is a child of): + * - an anchor or button element + * - OR an element with data-card-action + */ + forwardClicksToLink?: boolean + onClick?: React.MouseEventHandler + as?: React.ElementType +} & AriaAttributes export type ImageProps = NextImageProps & { size?: Size @@ -152,6 +220,7 @@ type TitleProps = { lines?: number style?: CSSProperties } + type SlotProps = { children?: ReactNode; style?: CSSProperties } type Card = FC & { @@ -163,7 +232,15 @@ type Card = FC & { Actions: FC } -const Card: Card = ({ children, className, size, href }) => { +const Card: Card = ({ + children, + className, + size, + href, + onClick, + forwardClicksToLink = false, + ...others +}) => { let content, image: ImageProps | null = null, info: SlotProps = {}, @@ -171,8 +248,6 @@ const Card: Card = ({ children, className, size, href }) => { footer: SlotProps = {}, actions: SlotProps = {} - const _Container = href ? LinkContainer : Container - /* * Allows rendering child elements to specific "slots": * @@ -196,54 +271,64 @@ const Card: Card = ({ children, className, size, href }) => { else if (child.type === Actions) actions = child.props }) + const hasHref = typeof href === "string" + const handleHrefClick = useClickChildHref(href, onClick) + const handleClick = hasHref && forwardClicksToLink ? handleHrefClick : onClick + const allClassNames = ["MitCard-root", className ?? ""].join(" ") if (content) { return ( - - <_Container className={className} href={href!}> - {content} - - + + {content} + ) } return ( - - <_Container href={href!} scroll={!href?.startsWith("?")}> - {image && ( - // alt text will be checked on Card.Image - // eslint-disable-next-line styled-components-a11y/alt-text - - )} - - {info.children && ( - - {info.children} - - )} - - {title.children} - - - -
- {footer.children} -
-
- - {actions.children && ( - - {actions.children} - + + {image && ( + // alt text will be checked on Card.Image + // eslint-disable-next-line styled-components-a11y/alt-text + )} -
+ + {info.children && ( + + {info.children} + + )} + + {title.children} + + + +
+ {footer.children} +
+ {actions.children && ( + + {actions.children} + + )} +
+ ) } diff --git a/frontends/ol-components/src/components/Card/ListCard.test.tsx b/frontends/ol-components/src/components/Card/ListCard.test.tsx index 3fc65edb90..bf18d6385a 100644 --- a/frontends/ol-components/src/components/Card/ListCard.test.tsx +++ b/frontends/ol-components/src/components/Card/ListCard.test.tsx @@ -1,17 +1,111 @@ -import { render } from "@testing-library/react" +import { render, screen } from "@testing-library/react" +import user from "@testing-library/user-event" import { ListCard } from "./ListCard" import React from "react" +import invariant from "tiny-invariant" +import { ThemeProvider } from "../ThemeProvider/ThemeProvider" describe("ListCard", () => { - test("has class MitCard-root on root element", () => { + test("has class MitListCard-root on root element", () => { const { container } = render( Hello world , + { wrapper: ThemeProvider }, ) const card = container.firstChild expect(card).toHaveClass("MitListCard-root") expect(card).toHaveClass("Foo") }) + + test.each([ + { forwardClicksToLink: true, finalHref: "#woof" }, + { forwardClicksToLink: false, finalHref: "" }, + ])( + "The whole card is clickable as a link", + async ({ forwardClicksToLink, finalHref }) => { + const href = "#woof" + render( + + Title + Info + Footer + Actions + , + { wrapper: ThemeProvider }, + ) + // outermost wrapper is not actually clickable + const card = document.querySelector(".MitListCard-root > *") + invariant(card instanceof HTMLDivElement) // Sanity: Chceck it's not an anchor + + await user.click(card) + expect(window.location.hash).toBe(finalHref) + }, + ) + + test.each([ + { forwardClicksToLink: true, finalHref: "#meow" }, + { forwardClicksToLink: false, finalHref: "" }, + ])( + "The whole card is clickable as a link when using Content, except buttons and links", + async ({ forwardClicksToLink, finalHref }) => { + const href = "#meow" + const onClick = jest.fn() + render( + + +
Hello!
+ + Link +
+
, + { wrapper: ThemeProvider }, + ) + const button = screen.getByRole("button", { name: "Button" }) + await user.click(button) + expect(onClick).toHaveBeenCalled() + expect(window.location.hash).toBe("") + + // outermost wrapper is not actually clickable + const card = document.querySelector(".MitListCard-root > *") + invariant(card instanceof HTMLDivElement) // Sanity: Chceck it's not an anchor + + await user.click(card) + expect(window.location.hash).toBe(finalHref) + }, + ) + + test("Clicks on interactive elements are not forwarded", async () => { + const btnOnClick = jest.fn() + const divOnClick = jest.fn() + render( + + Title + Info + + + Link Two + {/* + eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions + */} +
+ Interactive Div +
+
+
, + { wrapper: ThemeProvider }, + ) + const button = screen.getByRole("button", { name: "Button" }) + const link = screen.getByRole("link", { name: "Link Two" }) + const div = screen.getByText("Interactive Div") + await user.click(button) + expect(btnOnClick).toHaveBeenCalled() + expect(window.location.hash).toBe("") + await user.click(link) + expect(window.location.hash).toBe("#two") + await user.click(div) + expect(divOnClick).toHaveBeenCalled() + expect(window.location.hash).toBe("#two") + }) }) diff --git a/frontends/ol-components/src/components/Card/ListCard.tsx b/frontends/ol-components/src/components/Card/ListCard.tsx index 18f0ff33f1..31f44e5079 100644 --- a/frontends/ol-components/src/components/Card/ListCard.tsx +++ b/frontends/ol-components/src/components/Card/ListCard.tsx @@ -1,41 +1,22 @@ -import React, { FC, ReactNode, Children, isValidElement } from "react" -import Link from "next/link" +import React, { + FC, + ReactNode, + Children, + isValidElement, + AriaAttributes, +} from "react" import styled from "@emotion/styled" import { RiDraggable } from "@remixicon/react" import { theme } from "../ThemeProvider/ThemeProvider" -import { Wrapper, containerStyles, ImageProps } from "./Card" +import { BaseContainer, ImageProps, useClickChildHref, Linkable } from "./Card" import { TruncateText } from "../TruncateText/TruncateText" import { ActionButton, ActionButtonProps } from "../Button/Button" import { default as NextImage } from "next/image" -export const LinkContainer = styled(Link)` - ${containerStyles} - display: flex; - - :hover { - text-decoration: none; - border-color: ${theme.custom.colors.silverGrayLight}; - box-shadow: - 0 2px 4px 0 rgb(37 38 43 / 10%), - 0 2px 4px 0 rgb(37 38 43 / 10%); - cursor: pointer; - } -` - -export const Container = styled.div` - ${containerStyles} -` - -export const DraggableContainer = styled.div` - ${containerStyles} - display: flex; -` - const Content = () => <> export const Body = styled.div` flex-grow: 1; - overflow: hidden; margin: 24px; ${theme.breakpoints.down("md")} { margin: 12px; @@ -102,7 +83,7 @@ export const Info = styled.div` align-items: center; ` -export const Title = styled.span` +export const Title = styled(Linkable)` flex-grow: 1; color: ${theme.custom.colors.darkGray2}; text-overflow: ellipsis; @@ -136,19 +117,12 @@ export const Bottom = styled.div` /** * Slot intended to contain ListCardAction buttons. */ -export const Actions = styled.div<{ hasImage?: boolean }>` +export const Actions = styled.div` display: flex; gap: 8px; - position: absolute; - bottom: 24px; - right: ${({ hasImage }) => (hasImage ? "284px" : "24px")}; ${theme.breakpoints.down("md")} { - bottom: 8px; gap: 4px; - right: ${({ hasImage }) => (hasImage ? "120px" : "8px")}; } - - background-color: ${theme.custom.colors.white}; ` const ListCardActionButton = styled(ActionButton)<{ isMobile?: boolean }>( @@ -168,34 +142,63 @@ const ListCardActionButton = styled(ActionButton)<{ isMobile?: boolean }>( type CardProps = { children: ReactNode[] | ReactNode className?: string + /** + * If provided, the card will render its title as a link. + * + * Clicks on the entire card can be forwarded to the link via `forwardClicksToLink`. + */ href?: string + /** + * Defaults to `false`. If `true`, clicking the whole card will click the + * href link as well. + * + * NOTES: + * - If using Card.Content to customize, you must ensure the content includes + * an anchor with the card's href. + * - Clicks will NOT be forwarded if: + * - The click target is a child of Card.Actions OR an element with + * - The click target is a child of any element with data-card-actions attribute + */ + forwardClicksToLink?: boolean draggable?: boolean + onClick?: () => void + as?: React.ElementType +} & AriaAttributes +type TitleProps = { + children?: ReactNode } + export type Card = FC & { Content: FC<{ children: ReactNode }> Image: FC Info: FC<{ children: ReactNode }> - Title: FC<{ children: ReactNode }> + Title: FC Footer: FC<{ children: ReactNode }> Actions: FC<{ children: ReactNode }> Action: FC } -const ListCard: Card = ({ children, className, href, draggable }) => { - const _Container = draggable - ? DraggableContainer - : href - ? LinkContainer - : Container - - let content, imageProps, info, title, footer, actions +const ListCard: Card = ({ + children, + className, + href, + forwardClicksToLink = false, + draggable, + onClick, + ...others +}) => { + let content, imageProps, info, footer, actions + let title: TitleProps = {} + const hasHref = typeof href === "string" + const handleHrefClick = useClickChildHref(href, onClick) + const handleClick = hasHref && forwardClicksToLink ? handleHrefClick : onClick Children.forEach(children, (child) => { if (!isValidElement(child)) return if (child.type === Content) content = child.props.children else if (child.type === Image) imageProps = child.props else if (child.type === Info) info = child.props.children - else if (child.type === Title) title = child.props.children + else if (child.type === Title) title = child.props else if (child.type === Footer) footer = child.props.children else if (child.type === Actions) actions = child.props.children }) @@ -203,37 +206,42 @@ const ListCard: Card = ({ children, className, href, draggable }) => { const classNames = ["MitListCard-root", className ?? ""].join(" ") if (content) { return ( - <_Container className={classNames} href={href!}> + {content} - + ) } return ( - - <_Container href={href!} scroll={!href?.startsWith("?")}> - {draggable && ( - - - - )} - - {info} - - <TruncateText lineClamp={2}>{title}</TruncateText> + <BaseContainer + {...others} + className={classNames} + display="flex" + onClick={handleClick} + > + {draggable && ( + <DragArea> + <RiDraggable /> + </DragArea> + )} + <Body> + <Info>{info}</Info> + {title && ( + <Title {...title} href={href}> + <TruncateText lineClamp={2}>{title.children}</TruncateText> - -
{footer}
-
- - {imageProps && ( - // alt text will be checked on ListCard.Image - // eslint-disable-next-line styled-components-a11y/alt-text - )} - - {actions && {actions}} -
+ +
{footer}
+ {actions && {actions}} +
+ + {imageProps && ( + // alt text will be checked on ListCard.Image + // eslint-disable-next-line styled-components-a11y/alt-text + + )} + ) } diff --git a/frontends/ol-components/src/components/Card/ListCardCondensed.tsx b/frontends/ol-components/src/components/Card/ListCardCondensed.tsx index 58a49dfec5..e4529fa535 100644 --- a/frontends/ol-components/src/components/Card/ListCardCondensed.tsx +++ b/frontends/ol-components/src/components/Card/ListCardCondensed.tsx @@ -1,20 +1,22 @@ -import React, { FC, ReactNode, Children, isValidElement } from "react" +import React, { + FC, + ReactNode, + Children, + isValidElement, + AriaAttributes, +} from "react" import styled from "@emotion/styled" import { RiDraggable } from "@remixicon/react" import { theme } from "../ThemeProvider/ThemeProvider" -import { Wrapper } from "./Card" +import { BaseContainer, useClickChildHref } from "./Card" import { TruncateText } from "../TruncateText/TruncateText" import { ListCard, Body as BaseBody, - LinkContainer, - Container, - DraggableContainer, DragArea as BaseDragArea, Info as BaseInfo, Title as BaseTitle, Footer, - Actions as BaseActions, Bottom as BaseBottom, } from "./ListCard" import type { Card as BaseCard } from "./ListCard" @@ -56,36 +58,55 @@ const Bottom = styled(BaseBottom)` height: auto; } ` -const Actions = styled(BaseActions)` - bottom: 16px; - right: 16px; +const Actions = styled.div` + display: flex; gap: 16px; - ${theme.breakpoints.down("md")} { - bottom: 16px; - right: 16px; - gap: 16px; - } ` const Content = () => <> type CardProps = { children: ReactNode[] | ReactNode className?: string + /** + * If provided, the card will render its title as a link. + * + * Clicks on the entire card can be forwarded to the link via `forwardClicksToLink`. + */ href?: string + /** + * Defaults to `false`. If `true`, clicking the whole card will click the + * href link as well. + * + * NOTES: + * - If using Card.Content to customize, you must ensure the content includes + * an anchor with the card's href. + * - Clicks will NOT be forwarded if: + * - The click target is a child of Card.Actions OR an element with + * - The click target is a child of any element with data-card-actions attribute + */ + forwardClicksToLink?: boolean draggable?: boolean -} + onClick?: () => void + as?: React.ElementType +} & AriaAttributes type Card = FC & Omit -const ListCardCondensed: Card = ({ children, className, href, draggable }) => { - const _Container = draggable - ? DraggableContainer - : href - ? LinkContainer - : Container - +const ListCardCondensed: Card = ({ + children, + className, + href, + draggable, + onClick, + forwardClicksToLink = false, + ...others +}) => { let content, info, title, footer, actions + const hasHref = typeof href === "string" + const handleHrefClick = useClickChildHref(href, onClick) + const handleClick = hasHref && forwardClicksToLink ? handleHrefClick : onClick + Children.forEach(children, (child) => { if (!isValidElement(child)) return if (child.type === Content) content = child.props.children @@ -97,32 +118,30 @@ const ListCardCondensed: Card = ({ children, className, href, draggable }) => { if (content) { return ( - <_Container className={className} href={href!}> + {content} - + ) } return ( - - <_Container href={href!} scroll={!href?.startsWith("?")}> - {draggable && ( - - - - )} - - {info} - - <TruncateText lineClamp={4}>{title}</TruncateText> - - -
{footer}
-
- - - {actions && {actions}} -
+ + {draggable && ( + + + + )} + + {info} + + <TruncateText lineClamp={4}>{title}</TruncateText> + + +
{footer}
+ {actions && {actions}} +
+ +
) } diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.test.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.test.tsx index 9659c4812b..5187dbf505 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.test.tsx +++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.test.tsx @@ -1,5 +1,5 @@ import React from "react" -import { screen, render } from "@testing-library/react" +import { screen, render, within } from "@testing-library/react" import { LearningResourceCard } from "./LearningResourceCard" import type { LearningResourceCardProps } from "./LearningResourceCard" import { DEFAULT_RESOURCE_IMG, getReadableResourceType } from "ol-utilities" @@ -14,19 +14,29 @@ const setup = (props: LearningResourceCardProps) => { } describe("Learning Resource Card", () => { - test("Renders resource type, title and start date", () => { - const resource = factories.learningResources.resource({ - resource_type: ResourceTypeEnum.Course, - next_start_date: "2026-01-01", - }) - - setup({ resource }) - - screen.getByText("Course") - screen.getByText(resource.title) - screen.getByText("Starts:") - screen.getByText("January 01, 2026") - }) + test.each([ + { resourceType: ResourceTypeEnum.Course, expectedLabel: "Course" }, + { resourceType: ResourceTypeEnum.Program, expectedLabel: "Program" }, + ])( + "Renders resource type, title and start date as a labeled article", + ({ resourceType, expectedLabel }) => { + const resource = factories.learningResources.resource({ + resource_type: resourceType, + next_start_date: "2026-01-01", + }) + + setup({ resource }) + + const card = screen.getByRole("article", { + name: `${expectedLabel}: ${resource.title}`, + }) + + within(card).getByText(expectedLabel) + within(card).getByText(resource.title) + within(card).getByText("Starts:") + within(card).getByText("January 01, 2026") + }, + ) test("Displays run start date", () => { const resource = factories.learningResources.resource({ @@ -96,7 +106,7 @@ describe("Learning Resource Card", () => { setup({ resource, href: "/path/to/thing" }) const link = screen.getByRole("link", { - name: new RegExp(resource.title), + name: resource.title, }) expect(new URL(link.href).pathname).toBe("/path/to/thing") }) diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.tsx index 314d2b8912..059253ad62 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.tsx +++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.tsx @@ -229,8 +229,16 @@ const LearningResourceCard: React.FC = ({ return null } + const readableType = getReadableResourceType(resource.resource_type) return ( - + = ({ {onAddToUserListClick && ( onAddToUserListClick(event, resource.id)} > {inUserList ? ( diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx index d90eae55ae..7c635b098d 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx +++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx @@ -1,6 +1,6 @@ import React from "react" import { BrowserRouter } from "react-router-dom" -import { screen, render } from "@testing-library/react" +import { screen, render, within } from "@testing-library/react" import { LearningResourceListCard } from "./LearningResourceListCard" import type { LearningResourceListCardProps } from "./LearningResourceListCard" import { DEFAULT_RESOURCE_IMG, getReadableResourceType } from "ol-utilities" @@ -19,19 +19,29 @@ const setup = (props: LearningResourceListCardProps) => { } describe("Learning Resource List Card", () => { - test("Renders resource type, title and start date", () => { - const resource = factories.learningResources.resource({ - resource_type: ResourceTypeEnum.Course, - next_start_date: "2026-01-01", - }) + test.each([ + { resourceType: ResourceTypeEnum.Course, expectedLabel: "Course" }, + { resourceType: ResourceTypeEnum.Program, expectedLabel: "Program" }, + ])( + "Renders resource type, title and start date as a labeled article", + ({ resourceType, expectedLabel }) => { + const resource = factories.learningResources.resource({ + resource_type: resourceType, + next_start_date: "2026-01-01", + }) - setup({ resource }) + setup({ resource }) - screen.getByText("Course") - screen.getByText(resource.title) - screen.getByText("Starts:") - screen.getByText("January 01, 2026") - }) + const card = screen.getByRole("article", { + name: `${expectedLabel}: ${resource.title}`, + }) + + within(card).getByText(expectedLabel) + within(card).getByText(resource.title) + within(card).getByText("Starts:") + within(card).getByText("January 01, 2026") + }, + ) test("Displays run start date", () => { const resource = factories.learningResources.resource({ @@ -92,11 +102,11 @@ describe("Learning Resource List Card", () => { setup({ resource, href: "/path/to/thing" }) - const card = screen.getByRole("link", { - name: new RegExp(resource.title), + const link = screen.getByRole("link", { + name: resource.title, }) - expect(card).toHaveAttribute("href", "/path/to/thing") + expect(link).toHaveAttribute("href", "/path/to/thing") }) test("Click action buttons", async () => { diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.tsx index 6778afc08b..1757cffa63 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.tsx +++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.tsx @@ -307,8 +307,16 @@ const LearningResourceListCard: React.FC = ({ if (!resource) { return null } + const readableType = getReadableResourceType(resource.resource_type) return ( - + = ({ {onAddToUserListClick && ( onAddToUserListClick(event, resource.id)} > {inUserList ? ( diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCardCondensed.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCardCondensed.tsx index 2bb7c4a1cd..1b0bd72d94 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCardCondensed.tsx +++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCardCondensed.tsx @@ -115,8 +115,16 @@ const LearningResourceListCardCondensed: React.FC< if (!resource) { return null } + const readableType = getReadableResourceType(resource.resource_type) return ( - + @@ -134,7 +142,7 @@ const LearningResourceListCardCondensed: React.FC< {onAddToUserListClick && ( onAddToUserListClick(event, resource.id)} > {inUserList ? (