-
Notifications
You must be signed in to change notification settings - Fork 1
Catalog cards #63
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Catalog cards #63
Changes from 5 commits
82710ca
66774c3
4a683af
7f2343c
1e6cd1c
379d5ec
1ce92c5
fb6fb3f
a0014b9
841eef4
53df28e
071ca25
3f43f0f
1936678
b8e0e2d
63aa0fa
2df5b84
a99a3f5
5921457
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| .catalogContainer, .catalogList { | ||
| display: flex; | ||
| flex-direction: column; | ||
| } | ||
|
|
||
| .catalogList { | ||
| gap: 24px; | ||
| } | ||
|
|
||
| .catalogContainer { | ||
| height: 100%; | ||
| overflow-y: auto; | ||
| gap: 16px; | ||
| } | ||
|
|
||
| .catalogHeader { | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 8px; | ||
| } | ||
|
|
||
| .catalogHeader h2 { | ||
| font-size: 28; | ||
| font-weight: 700; | ||
| } | ||
|
|
||
| .catalogHeader span { | ||
| width: 16px; | ||
| height: 16px; | ||
| background: #000; | ||
| border-radius: 100%; | ||
| display: inline-flex; | ||
| justify-content: center; | ||
| align-items: center; | ||
| color: #fff; | ||
| font-size: 8px; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| import styles from "./Catalog.module.css"; | ||
| import { type BaseCatalog, CatalogCard } from "../core/CatalogCard"; | ||
|
|
||
| export interface CatalogProps { | ||
| catalogs: BaseCatalog[]; | ||
| } | ||
|
|
||
| export const Catalog = ({ catalogs, ...props }: CatalogProps) => ( | ||
| <div className={styles.catalogContainer} {...props}> | ||
| <div className={styles.catalogHeader}> | ||
| <h2>Catalogs</h2> | ||
| <span>{catalogs.length}</span> | ||
| </div> | ||
| <div className={styles.catalogList}> | ||
| {catalogs.map((catalog) => ( | ||
| <CatalogCard {...catalog} key={catalog.id} /> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| ); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| .cardContainer * { | ||
| padding: 0; | ||
| margin: 0; | ||
| } | ||
|
|
||
| .cardContainer { | ||
| display: flex; | ||
| flex-direction: column; | ||
| border-radius: 8px; | ||
| padding: 12px; | ||
| border: 1px solid #00000027; | ||
| gap: 32px; | ||
| } | ||
|
|
||
| .infoContainer { | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: 8px; | ||
| } | ||
|
|
||
| .title { | ||
| font-size: 20px; | ||
| } | ||
|
|
||
| .description { | ||
| font-size: var(--font-size-base); | ||
| font-weight: 400; | ||
| } | ||
|
|
||
| .date { | ||
| font-size: var(--font-size-small); | ||
| font-weight: 400; | ||
| color: #0000009b; | ||
| } | ||
|
|
||
| .footerContainer { | ||
| display: flex; | ||
| justify-content: space-between; | ||
| align-items: center; | ||
| } | ||
|
|
||
| .tag { | ||
| font-size: var(--font-size-base); | ||
| padding: 2px 6px; | ||
| border-radius: 3px; | ||
| color: white; | ||
| } | ||
|
|
||
| .tagAPI { | ||
| background-color: #FFC5003B; | ||
| color: #A16B00; | ||
| } | ||
|
|
||
| .tagCatalog { | ||
| background-color: #00000010; | ||
| color: #0000009B; | ||
| } | ||
|
|
||
| .browseButton { | ||
| color: #202020; | ||
| font-size: var(--font-size-base); | ||
| } | ||
|
|
||
| .readMore { | ||
| border: none; | ||
| text-decoration: underline; | ||
| cursor: pointer; | ||
| background: none; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| import { useMemo, useState, JSX } from "react"; | ||
| import { Button, Link } from "react-aria-components"; | ||
|
|
||
| import type { DateValue } from "react-aria"; | ||
| import styles from "./CatalogCard.module.css"; | ||
|
|
||
| export type TemporalExtent = [DateValue, DateValue?]; | ||
|
|
||
| export type IndicatorTag = "API" | "Catalog"; | ||
|
|
||
| export interface BaseCatalog { | ||
| // eslint-disable-next-line react/no-unused-prop-types | ||
|
||
| id: string; | ||
| title: string; | ||
| description: string; | ||
| temporalExtent: TemporalExtent; | ||
| indicatorTag?: IndicatorTag; | ||
| } | ||
|
|
||
| export interface CatalogCardProps extends BaseCatalog { | ||
| renderDescription?: (description: string) => JSX.Element; | ||
| } | ||
|
|
||
| const MAX_LENGTH = 250; | ||
|
|
||
| const Tag = ({ indicatorTag }: { indicatorTag: IndicatorTag }) => ( | ||
| <div | ||
| className={`${styles.tag} ${ | ||
| indicatorTag === "API" ? styles.tagAPI : styles.tagCatalog | ||
| }`} | ||
| > | ||
| {indicatorTag} | ||
| </div> | ||
| ); | ||
|
|
||
| export const CatalogCard = ({ | ||
| title, | ||
| description: initialDescription, | ||
| temporalExtent, | ||
| indicatorTag, | ||
| renderDescription, | ||
| }: CatalogCardProps) => { | ||
| const [shouldTruncateDescription, setShouldTruncateDescription] = | ||
| useState(true); | ||
|
|
||
| const isLongText = initialDescription.length > MAX_LENGTH; | ||
|
|
||
| const description = useMemo( | ||
| () => | ||
| shouldTruncateDescription | ||
| ? `${initialDescription.slice(0, MAX_LENGTH)}${isLongText ? "..." : ""}` | ||
| : initialDescription, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Consider moving this to a utility function. Something like this: There's actually a |
||
| [initialDescription, isLongText, shouldTruncateDescription], | ||
| ); | ||
|
|
||
| const dateRange = useMemo(() => { | ||
| const startDate = temporalExtent[0].toString(); | ||
|
|
||
| let endDate: string = ""; | ||
|
|
||
| // Check if end date exists and compare with start date | ||
| if ( | ||
| temporalExtent[1] && | ||
| temporalExtent[1].compare(temporalExtent[0]) !== 0 | ||
| ) { | ||
| endDate = temporalExtent[1].toString(); | ||
| } | ||
|
|
||
| return `${startDate}${endDate ? ` - ${endDate}` : ""}`; | ||
| }, [temporalExtent]); | ||
|
|
||
| const renderDefaultDescription = () => ( | ||
| <p className={styles.description}> | ||
| {description}{" "} | ||
| {isLongText && ( | ||
| <Button | ||
| className={styles.readMore} | ||
| onPress={() => | ||
| setShouldTruncateDescription(!shouldTruncateDescription) | ||
| } | ||
| aria-expanded={!shouldTruncateDescription} | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also add an |
||
| > | ||
| {shouldTruncateDescription ? "Read More" : "Read Less"} | ||
| </Button> | ||
| )} | ||
| </p> | ||
| ); | ||
|
|
||
| return ( | ||
| <div className={styles.cardContainer}> | ||
| <div className={styles.infoContainer}> | ||
| <h3 className={styles.title}>{title}</h3> | ||
| {renderDescription | ||
| ? renderDescription(initialDescription) | ||
| : renderDefaultDescription()} | ||
| <p className={styles.date}>{dateRange}</p> | ||
| </div> | ||
| <div className={styles.footerContainer}> | ||
| <div>{indicatorTag && <Tag indicatorTag={indicatorTag} />}</div> | ||
| <div> | ||
| <Link | ||
| href="#todo" | ||
| target="_blank" | ||
| className={styles.browseButton} | ||
| > | ||
| Browse | ||
| </Link> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| import type { Meta, StoryObj } from "@storybook/react"; | ||
|
|
||
| import { CalendarDateTime } from "@internationalized/date"; | ||
| import { Catalog } from "../components/composite/Catalog"; | ||
| import { BaseCatalog } from "../components/core/CatalogCard"; | ||
|
|
||
| const meta = { | ||
| title: "Composite/Catalog", | ||
| component: Catalog, | ||
| } satisfies Meta<typeof Catalog>; | ||
|
|
||
| export default meta; | ||
|
|
||
| type Story = StoryObj<typeof meta>; | ||
|
|
||
| const longDescription = | ||
| "The Kentucky From Above ([KyFromAbove](https://kyfromabove.ky.gov)) program has acquired aerial imagery and elevation (LiDAR) data for the Commonwealth of Kentucky since 2011. The catalog will be subdivided into separate catalogs for orthorectified imagery, oblique imagery, LiDAR-derived digital elevation models (DEM), and LiDAR point cloud data. The data acquired through KyFromAbove program is free to download and consume. All imagery within the catalog uses the Cloud-Optimized Geotiff (COG) format. With the exception of the Phase1 point cloud LAZ files, all LAZ files use the Cloud-Optimized Point Cloud (COPC) format.\n\nSee also:\n\n- [STAC Browser version](https://radiantearth.github.io/stac-browser/#/external/kyfromabove-stac.s3.us-west-1.amazonaws.com/catalog.json)"; | ||
|
|
||
| const catalogs: BaseCatalog[] = [ | ||
| { | ||
| id: "1", | ||
| title: "Catalog 1", | ||
| description: longDescription, | ||
| temporalExtent: [ | ||
| new CalendarDateTime(2024, 1, 1, 12, 22, 11), | ||
| ], | ||
| indicatorTag: "API", | ||
| }, | ||
| { | ||
| id: "2", | ||
| title: "Catalog 2", | ||
| description: "Catalog 2 Description", | ||
| temporalExtent: [ | ||
| new CalendarDateTime(2024, 1, 1), | ||
| new CalendarDateTime(2024, 1, 1), | ||
| ], | ||
| indicatorTag: "API", | ||
| }, | ||
| { | ||
| id: "3", | ||
| title: "Catalog 3", | ||
| description: "Catalog 3 Description", | ||
| temporalExtent: [ | ||
| new CalendarDateTime(2024, 1, 1), | ||
| new CalendarDateTime(2025, 1, 1), | ||
| ], | ||
| indicatorTag: "API", | ||
| }, | ||
| ]; | ||
|
|
||
| export const Default: Story = { | ||
| args: { | ||
| catalogs, | ||
| }, | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| import type { Meta, StoryObj } from "@storybook/react"; | ||
|
|
||
| import { CalendarDateTime } from "@internationalized/date"; | ||
| import { CatalogCard } from "../components/core/CatalogCard"; | ||
|
|
||
| const meta = { | ||
| title: "Core/CatalogCard", | ||
| component: CatalogCard, | ||
| } satisfies Meta<typeof CatalogCard>; | ||
|
|
||
| export default meta; | ||
|
|
||
| type Story = StoryObj<typeof meta>; | ||
|
|
||
| const shortDescription = "Lorem ipsum dolor sit amet"; | ||
| const longDescription = | ||
| "The Kentucky From Above ([KyFromAbove](https://kyfromabove.ky.gov)) program has acquired aerial imagery and elevation (LiDAR) data for the Commonwealth of Kentucky since 2011. The catalog will be subdivided into separate catalogs for orthorectified imagery, oblique imagery, LiDAR-derived digital elevation models (DEM), and LiDAR point cloud data. The data acquired through KyFromAbove program is free to download and consume. All imagery within the catalog uses the Cloud-Optimized Geotiff (COG) format. With the exception of the Phase1 point cloud LAZ files, all LAZ files use the Cloud-Optimized Point Cloud (COPC) format.\n\nSee also:\n\n- [STAC Browser version](https://radiantearth.github.io/stac-browser/#/external/kyfromabove-stac.s3.us-west-1.amazonaws.com/catalog.json)"; | ||
|
|
||
| export const Default: Story = { | ||
| args: { | ||
| title: "This is a title", | ||
| description: longDescription, | ||
| temporalExtent: [ | ||
| new CalendarDateTime(2024, 1, 1), | ||
| new CalendarDateTime(2025, 1, 1), | ||
| ], | ||
| indicatorTag: "API", | ||
| }, | ||
| }; | ||
|
|
||
| export const MissingDate: Story = { | ||
| args: { | ||
| title: "This is a title", | ||
| description: shortDescription, | ||
| temporalExtent: [new CalendarDateTime(2024, 1, 1)], | ||
| indicatorTag: "Catalog", | ||
| }, | ||
| }; | ||
|
|
||
| export const CustomDescriptionRender: Story = { | ||
| args: { | ||
| title: "This is a title", | ||
| description: longDescription, | ||
| temporalExtent: [new CalendarDateTime(2024, 1, 1)], | ||
| indicatorTag: "Catalog", | ||
| renderDescription: (description) => ( | ||
| <p style={{ color: "red" }}>{description}</p> | ||
| ), | ||
| }, | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could we be more specific about DateValue null/undefined cases?
Maybe not, but I'm interested to know what happens if the 1st DateValue in the array is nullish.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here we expect the library user to pass in one or two DateValue for TemporalExtent
So if user passes in
nullfor the first item or anything that doesn't satisfy the type, the editor is gonna show an error. I think type checking with typescript is good enough. Wdyt?Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, I am happy with this approach in that case
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've been putting some more thought into this as I modify the DatePicker component.
I'm not convinced that passing in props which use react-aria types like
DateValueis ideal. It introduces another dependency and requires the developer to understand the types in the library.There are a few alternatives, but I'm leaning to passing in regular JS
Dateobjects and converting them to a react-aria type likeDateValuewithin the component. What do you think?As an example, here's what I'm working on for the DatePicker: #66
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds good to me