mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 05:37:20 +08:00
feat(ui): accordion component (#9537)
This commit is contained in:
@@ -1 +1 @@
|
|||||||
1.2.16
|
1.2.17
|
||||||
@@ -9,7 +9,7 @@ bun install
|
|||||||
To run storybook:
|
To run storybook:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run sb
|
bun run --bun sb
|
||||||
```
|
```
|
||||||
|
|
||||||
This project was created using `bun init` in bun v1.2.16. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
|
This project was created using `bun init` in bun v1.2.16. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
|
||||||
|
|||||||
@@ -723,6 +723,8 @@
|
|||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"@testing-library/jest-dom/aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
|
||||||
|
|
||||||
"@testing-library/jest-dom/chalk": ["chalk@3.0.0", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg=="],
|
"@testing-library/jest-dom/chalk": ["chalk@3.0.0", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg=="],
|
||||||
|
|
||||||
"@testing-library/jest-dom/dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
|
"@testing-library/jest-dom/dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
|
||||||
|
|||||||
55
openhands-ui/components/accordion/Accordion.stories.tsx
Normal file
55
openhands-ui/components/accordion/Accordion.stories.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { Accordion, type AccordionProps } from "./Accordion";
|
||||||
|
import { useArray } from "../../shared/hooks/use-array";
|
||||||
|
import { Typography } from "../typography/Typography";
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "Components/Accordion",
|
||||||
|
parameters: {
|
||||||
|
layout: "centered",
|
||||||
|
},
|
||||||
|
tags: ["autodocs"],
|
||||||
|
} satisfies Meta;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
const AccordionComponent = ({ type }: { type: AccordionProps["type"] }) => {
|
||||||
|
const [keys, { replace }] = useArray(["foo"]);
|
||||||
|
return (
|
||||||
|
<div className="w-96">
|
||||||
|
<Accordion type={type} expandedKeys={keys} setExpandedKeys={replace}>
|
||||||
|
<Accordion.Item
|
||||||
|
value="foo"
|
||||||
|
label="file.txt"
|
||||||
|
icon={"FileEarmarkPlusFill"}
|
||||||
|
>
|
||||||
|
<Typography.Text>total 30</Typography.Text>
|
||||||
|
</Accordion.Item>
|
||||||
|
<Accordion.Item
|
||||||
|
value="bar"
|
||||||
|
label="foo.ext"
|
||||||
|
icon={"FileEarmarkPlusFill"}
|
||||||
|
>
|
||||||
|
<Typography.Text>total 60</Typography.Text>
|
||||||
|
</Accordion.Item>
|
||||||
|
<Accordion.Item
|
||||||
|
value="ipsum"
|
||||||
|
label="very_very_long_file_name_v3.pdf"
|
||||||
|
icon={"FileEarmarkPlusFill"}
|
||||||
|
>
|
||||||
|
<Typography.Text>total 90</Typography.Text>
|
||||||
|
</Accordion.Item>
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Multi: Story = {
|
||||||
|
render: () => <AccordionComponent type="multi" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Single: Story = {
|
||||||
|
render: () => <AccordionComponent type="single" />,
|
||||||
|
};
|
||||||
67
openhands-ui/components/accordion/Accordion.tsx
Normal file
67
openhands-ui/components/accordion/Accordion.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import React, { useCallback, type PropsWithChildren } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AccordionItem,
|
||||||
|
type AccordionItemPropsPublic,
|
||||||
|
} from "./components/AccordionItem";
|
||||||
|
import { cn } from "../../shared/utils/cn";
|
||||||
|
import type { HTMLProps } from "../../shared/types";
|
||||||
|
|
||||||
|
export type AccordionProps = HTMLProps<"div"> & {
|
||||||
|
expandedKeys: string[];
|
||||||
|
type?: "multi" | "single";
|
||||||
|
setExpandedKeys(keys: string[]): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AccordionType = React.FC<PropsWithChildren<AccordionProps>> & {
|
||||||
|
Item: React.FC<PropsWithChildren<AccordionItemPropsPublic>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Accordion: AccordionType = ({
|
||||||
|
className,
|
||||||
|
expandedKeys,
|
||||||
|
setExpandedKeys,
|
||||||
|
children,
|
||||||
|
type = "multi",
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const onChange = useCallback(
|
||||||
|
(key: string, expanded: boolean) => {
|
||||||
|
if (type === "multi") {
|
||||||
|
setExpandedKeys(
|
||||||
|
expanded
|
||||||
|
? [...expandedKeys, key]
|
||||||
|
: [...expandedKeys].filter((k) => k !== key)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setExpandedKeys(expanded ? [key] : []);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[expandedKeys, type]
|
||||||
|
);
|
||||||
|
|
||||||
|
const reactChildren = React.Children.toArray(children);
|
||||||
|
const items =
|
||||||
|
React.Children.map(reactChildren, (child: any) => {
|
||||||
|
const value = child.props.value;
|
||||||
|
const expanded = expandedKeys.some((key) => key === value);
|
||||||
|
return React.cloneElement(child, {
|
||||||
|
expanded,
|
||||||
|
onExpandedChange: () => onChange(value, !expanded),
|
||||||
|
className: "flex-1",
|
||||||
|
});
|
||||||
|
}) ?? [];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("flex flex-col gap-y-2.5 items-start", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{items}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Accordion.Item = AccordionItem as React.FC<
|
||||||
|
PropsWithChildren<AccordionItemPropsPublic>
|
||||||
|
>;
|
||||||
|
export { Accordion };
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import type { PropsWithChildren } from "react";
|
||||||
|
import type { HTMLProps } from "../../../shared/types";
|
||||||
|
import { cn } from "../../../shared/utils/cn";
|
||||||
|
import { Icon, type IconProps } from "../../icon/Icon";
|
||||||
|
import { Typography } from "../../typography/Typography";
|
||||||
|
|
||||||
|
export type AccordionHeaderProps = Omit<
|
||||||
|
HTMLProps<"button">,
|
||||||
|
"aria-disabled" | "disabled"
|
||||||
|
> & {
|
||||||
|
icon: IconProps["icon"];
|
||||||
|
expanded: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AccordionHeader = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
icon,
|
||||||
|
expanded,
|
||||||
|
...props
|
||||||
|
}: PropsWithChildren<AccordionHeaderProps>) => {
|
||||||
|
const iconCss = [
|
||||||
|
"shrink-0 text-light-neutral-500",
|
||||||
|
// expanded state
|
||||||
|
"group-data-[expanded=true]:text-light-neutral-15",
|
||||||
|
// hover modifier
|
||||||
|
"group-hover:text-light-neutral-15",
|
||||||
|
"group-focus:text-light-neutral-15",
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
{...props}
|
||||||
|
onBlur={() => console.log("blur")}
|
||||||
|
data-expanded={expanded}
|
||||||
|
className={cn(
|
||||||
|
"px-5.5 py-3.5 min-w-32 w-full",
|
||||||
|
"flex flex-row items-center gap-x-6 justify-between",
|
||||||
|
"group cursor-pointer",
|
||||||
|
// " focus:outline-0",
|
||||||
|
"ring-1 ring-solid ring-light-neutral-500",
|
||||||
|
expanded ? "rounded-t-2xl" : "rounded-2xl",
|
||||||
|
"data-[expanded=true]:bg-light-neutral-900 data-[expanded=false]:bg-grey-800",
|
||||||
|
// hover modifier
|
||||||
|
"data-[expanded=true]:hover:bg-light-neutral-900",
|
||||||
|
// focus modifier
|
||||||
|
"data-[expanded=false]:focus:bg-light-neutral-900"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon icon={icon} className={cn(iconCss, "w-6 h-6")} />
|
||||||
|
|
||||||
|
<Typography.Text
|
||||||
|
fontSize="m"
|
||||||
|
fontWeight={500}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 text-left truncate",
|
||||||
|
"text-light-neutral-500",
|
||||||
|
// expanded state
|
||||||
|
"group-data-[expanded=true]:text-light-neutral-15",
|
||||||
|
// hover modifier
|
||||||
|
"group-hover:text-light-neutral-15",
|
||||||
|
// focus modifier
|
||||||
|
"group-focus:text-light-neutral-15"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Typography.Text>
|
||||||
|
<Icon
|
||||||
|
icon={"ChevronUp"}
|
||||||
|
className={cn(
|
||||||
|
iconCss,
|
||||||
|
"h-4 w-4",
|
||||||
|
"transition-transform duration-300",
|
||||||
|
expanded && `rotate-180`
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { PropsWithChildren } from "react";
|
||||||
|
import type { HTMLProps } from "../../../shared/types";
|
||||||
|
import { cn } from "../../../shared/utils/cn";
|
||||||
|
import { type IconProps } from "../../icon/Icon";
|
||||||
|
import { AccordionHeader } from "./AccordionHeader";
|
||||||
|
import { AccordionPanel } from "./AccordionPanel";
|
||||||
|
|
||||||
|
export type AccordionItemProps = HTMLProps<"div"> & {
|
||||||
|
icon: IconProps["icon"];
|
||||||
|
expanded: boolean;
|
||||||
|
value: string;
|
||||||
|
label: React.ReactNode;
|
||||||
|
onExpandedChange(value: boolean): void;
|
||||||
|
};
|
||||||
|
export type AccordionItemPropsPublic = Omit<
|
||||||
|
AccordionItemProps,
|
||||||
|
"expanded" | "onExpandedChange"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const AccordionItem = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
expanded,
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
onExpandedChange,
|
||||||
|
...props
|
||||||
|
}: PropsWithChildren<AccordionItemProps>) => {
|
||||||
|
return (
|
||||||
|
<div {...props} className={cn("w-full", className)}>
|
||||||
|
<AccordionHeader
|
||||||
|
icon={icon}
|
||||||
|
expanded={expanded}
|
||||||
|
onClick={() => onExpandedChange(!expanded)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</AccordionHeader>
|
||||||
|
<AccordionPanel expanded={expanded}>{children}</AccordionPanel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { PropsWithChildren } from "react";
|
||||||
|
import type { HTMLProps } from "../../../shared/types";
|
||||||
|
import { cn } from "../../../shared/utils/cn";
|
||||||
|
|
||||||
|
export type AccordionPanelProps = Omit<HTMLProps<"div">, "aria-expanded"> & {
|
||||||
|
expanded: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AccordionPanel = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
expanded,
|
||||||
|
...props
|
||||||
|
}: PropsWithChildren<AccordionPanelProps>) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-expanded={expanded}
|
||||||
|
className={cn(
|
||||||
|
"px-6 py-4",
|
||||||
|
"ring-1 ring-solid ring-light-neutral-500 bg-grey-800",
|
||||||
|
"rounded-b-2xl",
|
||||||
|
"aria-[expanded=false]:hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
64
openhands-ui/shared/hooks/use-array.ts
Normal file
64
openhands-ui/shared/hooks/use-array.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
|
type ArrayActions<T> = {
|
||||||
|
push: (value: T | T[]) => void;
|
||||||
|
replace: (value: T | T[]) => void;
|
||||||
|
addAtIndex: (index: number, value: T) => void;
|
||||||
|
removeAt: (index: number) => void;
|
||||||
|
remove: (value: T, compareBy?: keyof T) => void;
|
||||||
|
subset: (indexStart: number, indexEnd: number) => void;
|
||||||
|
clear: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useArray<T>(initialValue: T | T[]): [T[], ArrayActions<T>] {
|
||||||
|
const [array, setArray] = useState<T[]>(
|
||||||
|
Array.isArray(initialValue) ? initialValue : [initialValue]
|
||||||
|
);
|
||||||
|
|
||||||
|
const push = useCallback((value: T | T[]) => {
|
||||||
|
const values = Array.isArray(value) ? value : [value];
|
||||||
|
setArray((prev) => [...prev, ...values]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const replace = useCallback((value: T | T[]) => {
|
||||||
|
setArray(Array.isArray(value) ? value : [value]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addAtIndex = useCallback((index: number, value: T) => {
|
||||||
|
setArray((prev) => [
|
||||||
|
...prev.slice(0, index + 1),
|
||||||
|
value,
|
||||||
|
...prev.slice(index + 1),
|
||||||
|
]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeAt = useCallback((index: number) => {
|
||||||
|
setArray((prev) => [...prev.slice(0, index), ...prev.slice(index + 1)]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const remove = useCallback((value: T, compareBy?: keyof T) => {
|
||||||
|
setArray((prev) => {
|
||||||
|
const index = prev.findIndex((item) =>
|
||||||
|
compareBy
|
||||||
|
? isEqual(item[compareBy], value[compareBy])
|
||||||
|
: isEqual(item, value)
|
||||||
|
);
|
||||||
|
return index >= 0
|
||||||
|
? [...prev.slice(0, index), ...prev.slice(index + 1)]
|
||||||
|
: prev;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const subset = useCallback((indexStart: number, indexEnd: number) => {
|
||||||
|
setArray((prev) => prev.slice(indexStart, indexEnd + 1));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clear = useCallback(() => {
|
||||||
|
setArray([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return [
|
||||||
|
array,
|
||||||
|
{ push, replace, addAtIndex, removeAt, remove, subset, clear },
|
||||||
|
];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user