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..44bc2d8e8 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/components/Items/components/Item/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/AccordionButton.tsx b/packages/components/src/components/List/components/Items/components/Item/components/AccordionButton.tsx new file mode 100644 index 000000000..68bcee0d8 --- /dev/null +++ b/packages/components/src/components/List/components/Items/components/Item/components/AccordionButton.tsx @@ -0,0 +1,38 @@ +import type { FC, PropsWithChildren } from "react"; +import React from "react"; +import { Button } from "@/components/Button"; +import { + IconChevronDown, + IconChevronUp, +} from "@/components/Icon/components/icons"; +import locales from "../../../../../locales/*.locale.json"; +import { useLocalizedStringFormatter } from "react-aria"; + +interface Props extends PropsWithChildren { + isExpanded: boolean; + toggle: () => void; + contentElementId: string; +} + +export const AccordionButton: FC = (props) => { + const { isExpanded, toggle, children, contentElementId } = props; + const stringFormatter = useLocalizedStringFormatter(locales); + + return ( + <> + + {isExpanded && 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/Items/components/Item/hooks/useGridItemProps.tsx b/packages/components/src/components/List/components/Items/components/Item/hooks/useGridItemProps.tsx new file mode 100644 index 000000000..04354995d --- /dev/null +++ b/packages/components/src/components/List/components/Items/components/Item/hooks/useGridItemProps.tsx @@ -0,0 +1,79 @@ +import type { PropsWithChildren } from "react"; +import React, { useEffect, useId, useRef, useState } from "react"; +import { useList } from "@/components/List"; +import type { PropsContext } from "@/lib/propsContext"; +import { dynamic, PropsContextProvider } from "@/lib/propsContext"; +import { AccordionButton } from "@/components/List/components/Items/components/Item/components/AccordionButton"; + +interface P extends PropsWithChildren { + data: never; +} + +export const useGridItemProps = (props: P) => { + const { data, children: childrenFromProps } = props; + 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" ? ( + + ) : 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..67be8742a 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,42 @@ 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} + + + {invoice.date} - {invoice.amount} + + + + )} + + +
+ ); + }, +}; 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..086298649 --- /dev/null +++ b/packages/docs/src/content/03-components/structure/list/examples/accordion.tsx @@ -0,0 +1,48 @@ +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"; + + + + > + {(domain) => ( + + + {domain.type === "Domain" ? ( + + ) : ( + + )} + + + {domain.hostname} + {!domain.verified && ( + + Unverifiziert + + )} + + {domain.type} + Mehr Inhalt + + )} + +; diff --git a/packages/docs/src/content/03-components/structure/list/overview.mdx b/packages/docs/src/content/03-components/structure/list/overview.mdx index 07a3c1899..3cd8e3ff9 100644 --- a/packages/docs/src/content/03-components/structure/list/overview.mdx +++ b/packages/docs/src/content/03-components/structure/list/overview.mdx @@ -60,3 +60,11 @@ Verwende eine `` um eine Zusammenfassung, wie beispielsweise die Summe der Beträge, anzuzeigen. + +## Mit Accordion + +Aktiviere das Accordion-Verhalten über die `accordion` Property. So lässt sich +ein Listen-Element durch Klick ein- bzw. ausklappen. Der erweiterte Inhalt muss +dann in `` enthalten sein. + +