mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-25 21:36:52 +08:00
feat(ui): accordion component (#9537)
This commit is contained in:
parent
9ee2f976a1
commit
2066f90654
@ -1 +1 @@
|
||||
1.2.16
|
||||
1.2.17
|
||||
@ -9,7 +9,7 @@ bun install
|
||||
To run storybook:
|
||||
|
||||
```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.
|
||||
|
||||
@ -723,6 +723,8 @@
|
||||
|
||||
"@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/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 },
|
||||
];
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user