From 783efef9a3b32502c29c41593976bab70117b49d Mon Sep 17 00:00:00 2001 From: Marco Falkenberg Date: Tue, 19 Nov 2024 10:44:22 +0100 Subject: [PATCH] feat(List): add option to toggle list details --- .../components/Items/components/Item/Item.tsx | 16 +-- .../components/Item/components/View/View.tsx | 2 +- .../List/components/Table/Table.tsx | 12 +- .../List/hooks/useGridItemProps.tsx | 117 ++++++++++++++++++ .../components/List/locales/de-DE.locale.json | 4 +- .../components/List/locales/en-EN.locale.json | 4 +- .../src/components/List/model/List.ts | 3 + .../components/List/model/item/ItemView.ts | 5 +- .../src/components/List/model/types.ts | 1 + .../List/stories/Default.stories.tsx | 41 ++++++ .../structure/list/examples/accordion.tsx | 50 ++++++++ .../03-components/structure/list/overview.mdx | 11 ++ 12 files changed, 251 insertions(+), 15 deletions(-) create mode 100644 packages/components/src/components/List/hooks/useGridItemProps.tsx create mode 100644 packages/docs/src/content/03-components/structure/list/examples/accordion.tsx diff --git a/packages/components/src/components/List/components/Items/components/Item/Item.tsx b/packages/components/src/components/List/components/Items/components/Item/Item.tsx index d7ce4d26b..c34aaa515 100644 --- a/packages/components/src/components/List/components/Items/components/Item/Item.tsx +++ b/packages/components/src/components/List/components/Items/components/Item/Item.tsx @@ -6,6 +6,7 @@ import type { Key } from "react-aria-components"; import * as Aria from "react-aria-components"; import { useList } from "@/components/List/hooks/useList"; import { SkeletonView } from "@/components/List/components/Items/components/Item/components/SkeletonView/SkeletonView"; +import { useGridItemProps } from "@/components/List/hooks/useGridItemProps"; interface Props extends PropsWithChildren { id: Key; @@ -13,19 +14,20 @@ interface Props extends PropsWithChildren { } export const Item = (props: Props) => { - const { id, data, children } = props; + const { id, data } = props; const list = useList(); + const itemView = list.itemView; + const { gridItemProps, children } = useGridItemProps(props); + if (!itemView) { return null; } - const onAction = itemView.list.onAction; - const textValue = itemView.textValue ? itemView.textValue(data) : undefined; const href = itemView.href ? itemView.href(data) : undefined; - const hasAction = !!onAction || !!href; + const hasAction = !!gridItemProps.onAction || !!href; return ( { props.isSelected && styles.isSelected, ) } - onAction={() => onAction && onAction(data)} textValue={textValue} href={href} + {...gridItemProps} > - }> - {children ?? itemView.render(data)} - + }>{children} ); }; diff --git a/packages/components/src/components/List/components/Items/components/Item/components/View/View.tsx b/packages/components/src/components/List/components/Items/components/Item/components/View/View.tsx index 69222d786..cc3384c82 100644 --- a/packages/components/src/components/List/components/Items/components/Item/components/View/View.tsx +++ b/packages/components/src/components/List/components/Items/components/Item/components/View/View.tsx @@ -68,7 +68,7 @@ export const View = (props: Props) => { return (
- + {children}
diff --git a/packages/components/src/components/List/components/Table/Table.tsx b/packages/components/src/components/List/components/Table/Table.tsx index 2d55cfe86..2061e9c6f 100644 --- a/packages/components/src/components/List/components/Table/Table.tsx +++ b/packages/components/src/components/List/components/Table/Table.tsx @@ -34,7 +34,7 @@ export const Table: FC = () => { return ; } - const rowAction = table.list.onAction; + const onAction = list.onAction; const tableClassName = clsx( styles.table, @@ -42,6 +42,12 @@ export const Table: FC = () => { table.componentProps.className, ); + const handleAction = (data: never) => () => { + if (typeof onAction === "function") { + onAction(data); + } + }; + return ( { className={(props) => clsx( styles.row, - rowAction && styles.hasAction, + onAction && styles.hasAction, table.body.row.componentProps.className, props.isSelected && styles.isSelected, ) } key={item.id} id={item.id} - onAction={rowAction ? () => rowAction(item.data) : undefined} + onAction={handleAction(item.data)} {...table.body.row.componentProps} > {table.body.row?.cells.map((cell, i) => ( diff --git a/packages/components/src/components/List/hooks/useGridItemProps.tsx b/packages/components/src/components/List/hooks/useGridItemProps.tsx new file mode 100644 index 000000000..4cf85ee93 --- /dev/null +++ b/packages/components/src/components/List/hooks/useGridItemProps.tsx @@ -0,0 +1,117 @@ +import type { PropsWithChildren } from "react"; +import React, { useEffect, useId, useRef, useState } from "react"; +import { useLocalizedStringFormatter } from "react-aria"; +import locales from "../locales/*.locale.json"; +import { useList } from "@/components/List"; +import type { PropsContext } from "@/lib/propsContext"; +import { dynamic, PropsContextProvider } from "@/lib/propsContext"; +import { + IconChevronDown, + IconChevronUp, +} from "@/components/Icon/components/icons"; + +const NoOpRender = () => null; + +interface P extends PropsWithChildren { + data: never; +} + +export const useGridItemProps = (props: P) => { + const { data, children: childrenFromProps } = props; + const stringFormatter = useLocalizedStringFormatter(locales); + const list = useList(); + const itemView = list.itemView; + const onAction = list.onAction; + + const [isExpanded, setIsExpanded] = useState( + itemView?.defaultExpanded?.(data) ?? false, + ); + const contentElementId = useId(); + const itemRef = useRef(null); + + const accordion = list.accordion; + const children = childrenFromProps ?? itemView?.render(data); + + useEffect(() => { + if (accordion) { + itemRef.current?.setAttribute("aria-expanded", String(isExpanded)); + itemRef.current?.setAttribute("aria-controls", contentElementId); + } + }, [isExpanded, contentElementId, itemRef.current, accordion]); + + if (!accordion) { + return { + gridItemProps: { + onAction: () => { + onAction?.(data); + }, + }, + children, + }; + } + + const toggleAccordion = () => { + setIsExpanded((current) => !current); + onAction?.(data); + }; + + const propsContext: PropsContext = { + Content: { + id: dynamic((p) => (p.slot === "bottom" ? contentElementId : undefined)), + wrapWith: dynamic((p) => + p.slot === "bottom" ? ( + isExpanded ? undefined : ( + + ) + ) : undefined, + ), + }, + + Button: { + children: dynamic((p) => + p.slot === "toggle" ? ( + isExpanded ? ( + + ) : ( + + ) + ) : undefined, + ), + variant: dynamic((p) => (p.slot === "toggle" ? "plain" : undefined)), + color: dynamic((p) => (p.slot === "toggle" ? "secondary" : undefined)), + onPress: dynamic((p) => + p.slot === "toggle" ? toggleAccordion : undefined, + ), + "aria-label": dynamic((p) => + p.slot === "toggle" + ? stringFormatter.format( + isExpanded + ? "list.toggleExpandButton.collapse" + : "list.toggleExpandButton.expand", + ) + : undefined, + ), + "aria-controls": dynamic((p) => + p.slot === "toggle" ? contentElementId : undefined, + ), + "aria-expanded": dynamic((p) => + p.slot === "toggle" ? isExpanded : undefined, + ), + }, + }; + + return { + gridItemProps: { + ref: itemRef, + onAction: toggleAccordion, + }, + children: ( + + {children} + + ), + }; +}; diff --git a/packages/components/src/components/List/locales/de-DE.locale.json b/packages/components/src/components/List/locales/de-DE.locale.json index 72a7677f7..6a4f7f5af 100644 --- a/packages/components/src/components/List/locales/de-DE.locale.json +++ b/packages/components/src/components/List/locales/de-DE.locale.json @@ -11,5 +11,7 @@ "list.settings.viewMode.list": "Liste", "list.settings.viewMode.table": "Tabelle", "list.showMore": "Mehr anzeigen", - "list.sorting": "Sortierung" + "list.sorting": "Sortierung", + "list.toggleExpandButton.collapse": "Weniger anzeigen", + "list.toggleExpandButton.expand": "Mehr anzeigen" } diff --git a/packages/components/src/components/List/locales/en-EN.locale.json b/packages/components/src/components/List/locales/en-EN.locale.json index 9b36f8e0f..0220f90f4 100644 --- a/packages/components/src/components/List/locales/en-EN.locale.json +++ b/packages/components/src/components/List/locales/en-EN.locale.json @@ -11,5 +11,7 @@ "list.settings.viewMode.list": "List", "list.settings.viewMode.table": "Table", "list.showMore": "Show more", - "list.sorting": "Sorting" + "list.sorting": "Sorting", + "list.toggleExpandButton.collapse": "Show less", + "list.toggleExpandButton.expand": "Show more" } diff --git a/packages/components/src/components/List/model/List.ts b/packages/components/src/components/List/model/List.ts index 06a2645e5..acc59c203 100644 --- a/packages/components/src/components/List/model/List.ts +++ b/packages/components/src/components/List/model/List.ts @@ -31,6 +31,7 @@ export class List { public readonly batches: BatchesController; public readonly loader: IncrementalLoader; public readonly onAction?: ItemActionFn; + public readonly accordion: boolean; public readonly getItemId?: GetItemId; public readonly componentProps: ListSupportedComponentProps; public viewMode: ListViewMode; @@ -60,6 +61,7 @@ export class List { onAction, getItemId, defaultViewMode, + accordion = false, ...componentProps } = shape; @@ -78,6 +80,7 @@ export class List { this.sorting = sorting.map((shape) => new Sorting(this, shape)); this.search = search ? new Search(this, search) : undefined; this.itemView = itemView ? new ItemView(this, itemView) : undefined; + this.accordion = accordion; this.table = table ? new Table(this, table) : undefined; this.batches = new BatchesController(this, batchesController); this.componentProps = componentProps; diff --git a/packages/components/src/components/List/model/item/ItemView.ts b/packages/components/src/components/List/model/item/ItemView.ts index 499078177..fdc932a1f 100644 --- a/packages/components/src/components/List/model/item/ItemView.ts +++ b/packages/components/src/components/List/model/item/ItemView.ts @@ -6,6 +6,7 @@ import type List from "@/components/List/model/List"; export interface ItemViewShape { textValue?: (data: T) => string; href?: (data: T) => string; + defaultExpanded?: (data: T) => boolean; renderFn?: RenderItemFn; fallback?: ReactElement; } @@ -14,15 +15,17 @@ export class ItemView { public readonly list: List; public readonly textValue?: (data: T) => string; public readonly href?: (data: T) => string; + public readonly defaultExpanded?: (data: T) => boolean; public readonly fallback?: ReactElement; private readonly renderFn?: RenderItemFn; public constructor(list: List, shape: ItemViewShape = {}) { - const { fallback, textValue, href, renderFn } = shape; + const { fallback, textValue, href, defaultExpanded, renderFn } = shape; this.list = list; this.textValue = textValue; this.renderFn = renderFn; this.href = href; + this.defaultExpanded = defaultExpanded; this.fallback = fallback; } diff --git a/packages/components/src/components/List/model/types.ts b/packages/components/src/components/List/model/types.ts index 41f0ab7cf..a9a668645 100644 --- a/packages/components/src/components/List/model/types.ts +++ b/packages/components/src/components/List/model/types.ts @@ -42,6 +42,7 @@ export interface ListShape extends ListSupportedComponentProps { table?: TableShape; onAction?: ItemActionFn; + accordion?: boolean; getItemId?: GetItemId; onChange?: OnListChanged; defaultViewMode?: ListViewMode; diff --git a/packages/components/src/components/List/stories/Default.stories.tsx b/packages/components/src/components/List/stories/Default.stories.tsx index 7829c0821..ee1aadeff 100644 --- a/packages/components/src/components/List/stories/Default.stories.tsx +++ b/packages/components/src/components/List/stories/Default.stories.tsx @@ -16,6 +16,7 @@ import { ListItemView, ListSummary, typedList } from "@/components/List"; import { Button } from "@/components/Button"; import IconDownload from "@/components/Icon/components/icons/IconDownload"; import { ActionGroup } from "@/components/ActionGroup"; +import { Content } from "@/components/Content"; const loadDomains: AsyncDataLoader = async (opts) => { const response = await getDomains({ @@ -178,3 +179,43 @@ export const WithSummary: Story = { ); }, }; + +export const WithAccordion: Story = { + render: () => { + const InvoiceList = typedList<{ + id: string; + date: string; + amount: string; + }>(); + + return ( +
+ Invoices + + + invoice.id === "RG100001"} + > + {(invoice) => ( + + {invoice.id} +
+ ); + }, +}; diff --git a/packages/docs/src/content/03-components/structure/list/examples/accordion.tsx b/packages/docs/src/content/03-components/structure/list/examples/accordion.tsx new file mode 100644 index 000000000..74ede0480 --- /dev/null +++ b/packages/docs/src/content/03-components/structure/list/examples/accordion.tsx @@ -0,0 +1,50 @@ +import { + List, + ListItem, + ListItemView, + ListStaticData, +} from "@mittwald/flow-react-components/List"; +import { + type Domain, + domains, +} from "@/content/03-components/structure/list/examples/domainApi"; +import Avatar from "@mittwald/flow-react-components/Avatar"; +import Heading from "@mittwald/flow-react-components/Heading"; +import Text from "@mittwald/flow-react-components/Text"; +import { + IconDomain, + IconSubdomain, +} from "@mittwald/flow-react-components/Icons"; +import AlertBadge from "@mittwald/flow-react-components/AlertBadge"; +import Content from "@mittwald/flow-react-components/Content"; +import Button from "@mittwald/flow-react-components/Button"; + + + + > + {(domain) => ( + + + {domain.type === "Domain" ? ( + + ) : ( + + )} + + + {domain.hostname} + {!domain.verified && ( + + Unverifiziert + + )} + + {domain.type} +