Skip to content

Commit

Permalink
Merge pull request #991 from mittwald/984-expandable-list-item
Browse files Browse the repository at this point in the history
feat(List): add option to toggle list details
  • Loading branch information
mfal authored Nov 19, 2024
2 parents d552a3a + 1bc22a4 commit cf7075a
Show file tree
Hide file tree
Showing 12 changed files with 236 additions and 12 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,28 @@ 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;
data: never;
}

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 (
<Aria.GridListItem
Expand All @@ -37,13 +39,11 @@ export const Item = (props: Props) => {
props.isSelected && styles.isSelected,
)
}
onAction={() => onAction && onAction(data)}
textValue={textValue}
href={href}
{...gridItemProps}
>
<Suspense fallback={<SkeletonView />}>
{children ?? itemView.render(data)}
</Suspense>
<Suspense fallback={<SkeletonView />}>{children}</Suspense>
</Aria.GridListItem>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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> = (props) => {
const { isExpanded, toggle, children, contentElementId } = props;
const stringFormatter = useLocalizedStringFormatter(locales);

return (
<>
<Button
variant="plain"
color="secondary"
onPress={toggle}
aria-label={stringFormatter.format(
"list.toggleExpandButton." + (isExpanded ? "collapse" : "expand"),
)}
aria-controls={contentElementId}
aria-expanded={isExpanded}
>
{isExpanded ? <IconChevronUp /> : <IconChevronDown />}
</Button>
{isExpanded && children}
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export const View = (props: Props) => {

return (
<div className={rootClassName}>
<PropsContextProvider props={propsContext}>
<PropsContextProvider props={propsContext} mergeInParentContext>
<TunnelProvider>
{children}
<div className={styles.title}>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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" ? (
<AccordionButton
contentElementId={contentElementId}
toggle={toggleAccordion}
isExpanded={isExpanded}
/>
) : undefined,
),
},
};

return {
gridItemProps: {
ref: itemRef,
onAction: toggleAccordion,
},
children: (
<PropsContextProvider
props={propsContext}
dependencies={[contentElementId, isExpanded]}
>
{children}
</PropsContextProvider>
),
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
3 changes: 3 additions & 0 deletions packages/components/src/components/List/model/List.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export class List<T> {
public readonly batches: BatchesController<T>;
public readonly loader: IncrementalLoader<T>;
public readonly onAction?: ItemActionFn<T>;
public readonly accordion: boolean;
public readonly getItemId?: GetItemId<T>;
public readonly componentProps: ListSupportedComponentProps;
public viewMode: ListViewMode;
Expand Down Expand Up @@ -60,6 +61,7 @@ export class List<T> {
onAction,
getItemId,
defaultViewMode,
accordion = false,
...componentProps
} = shape;

Expand All @@ -78,6 +80,7 @@ export class List<T> {
this.sorting = sorting.map((shape) => new Sorting<T>(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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type List from "@/components/List/model/List";
export interface ItemViewShape<T> {
textValue?: (data: T) => string;
href?: (data: T) => string;
defaultExpanded?: (data: T) => boolean;
renderFn?: RenderItemFn<T>;
fallback?: ReactElement;
}
Expand All @@ -14,15 +15,17 @@ export class ItemView<T> {
public readonly list: List<T>;
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<T>;

public constructor(list: List<T>, shape: ItemViewShape<T> = {}) {
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;
}

Expand Down
1 change: 1 addition & 0 deletions packages/components/src/components/List/model/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export interface ListShape<T> extends ListSupportedComponentProps {
table?: TableShape<T>;

onAction?: ItemActionFn<T>;
accordion?: boolean;
getItemId?: GetItemId<T>;
onChange?: OnListChanged<T>;
defaultViewMode?: ListViewMode;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Domain> = async (opts) => {
const response = await getDomains({
Expand Down Expand Up @@ -178,3 +179,42 @@ export const WithSummary: Story = {
);
},
};

export const WithAccordion: Story = {
render: () => {
const InvoiceList = typedList<{
id: string;
date: string;
amount: string;
}>();

return (
<Section>
<Heading>Invoices</Heading>
<InvoiceList.List batchSize={5} aria-label="Invoices" accordion>
<InvoiceList.StaticData
data={[
{ id: "RG100000", date: "1.9.2024", amount: "25,00 €" },
{ id: "RG100001", date: "12.9.2024", amount: "12,00 €" },
{ id: "RG100002", date: "3.10.2024", amount: "4,00 €" },
]}
/>
<InvoiceList.Item
defaultExpanded={(invoice) => invoice.id === "RG100001"}
>
{(invoice) => (
<ListItemView>
<Heading>{invoice.id}</Heading>
<Content slot="bottom">
<Text>
{invoice.date} - {invoice.amount}
</Text>
</Content>
</ListItemView>
)}
</InvoiceList.Item>
</InvoiceList.List>
</Section>
);
},
};
Original file line number Diff line number Diff line change
@@ -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";

<List batchSize={2} accordion>
<ListStaticData data={domains} />
<ListItem<Domain>>
{(domain) => (
<ListItemView>
<Avatar
color={domain.type === "Domain" ? "blue" : "teal"}
>
{domain.type === "Domain" ? (
<IconDomain />
) : (
<IconSubdomain />
)}
</Avatar>
<Heading>
{domain.hostname}
{!domain.verified && (
<AlertBadge status="warning">
Unverifiziert
</AlertBadge>
)}
</Heading>
<Text>{domain.type}</Text>
<Content slot="bottom">Mehr Inhalt</Content>
</ListItemView>
)}
</ListItem>
</List>;
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,11 @@ Verwende eine `<ListSummary/>` um eine Zusammenfassung, wie beispielsweise die
Summe der Beträge, anzuzeigen.

<LiveCodeEditor example="summary" editorCollapsed />

## 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 `<Content slot="bottom" />` enthalten sein.

<LiveCodeEditor example="accordion" editorCollapsed />

0 comments on commit cf7075a

Please sign in to comment.