Skip to content

Commit

Permalink
feat(List): add option to toggle list details
Browse files Browse the repository at this point in the history
  • Loading branch information
mfal committed Nov 19, 2024
1 parent 9abd015 commit 783efef
Show file tree
Hide file tree
Showing 12 changed files with 251 additions and 15 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/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
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
Expand Up @@ -34,14 +34,20 @@ export const Table: FC = () => {
return <EmptyView />;
}

const rowAction = table.list.onAction;
const onAction = list.onAction;

const tableClassName = clsx(
styles.table,
isLoading && styles.isLoading,
table.componentProps.className,
);

const handleAction = (data: never) => () => {
if (typeof onAction === "function") {
onAction(data);
}
};

return (
<TableComponent
{...list.componentProps}
Expand All @@ -62,14 +68,14 @@ export const Table: FC = () => {
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) => (
Expand Down
117 changes: 117 additions & 0 deletions packages/components/src/components/List/hooks/useGridItemProps.tsx
Original file line number Diff line number Diff line change
@@ -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<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" ? (
isExpanded ? undefined : (
<NoOpRender />
)
) : undefined,
),
},

Button: {
children: dynamic((p) =>
p.slot === "toggle" ? (
isExpanded ? (
<IconChevronUp />
) : (
<IconChevronDown />
)
) : 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: (
<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,43 @@ 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>
<Button slot="toggle" />
<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,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";

<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>
<Button slot="toggle" />
<Content slot="bottom">Mehr Inhalt</Content>
</ListItemView>
)}
</ListItem>
</List>;
Loading

0 comments on commit 783efef

Please sign in to comment.