diff --git a/openhands-ui/.bun-version b/openhands-ui/.bun-version index a96f385f15..21344eb17a 100644 --- a/openhands-ui/.bun-version +++ b/openhands-ui/.bun-version @@ -1 +1 @@ -1.2.16 \ No newline at end of file +1.2.17 \ No newline at end of file diff --git a/openhands-ui/README.md b/openhands-ui/README.md index 3e29b84821..a8242dc4aa 100644 --- a/openhands-ui/README.md +++ b/openhands-ui/README.md @@ -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. diff --git a/openhands-ui/bun.lock b/openhands-ui/bun.lock index 899514daf2..cd6069eb6c 100644 --- a/openhands-ui/bun.lock +++ b/openhands-ui/bun.lock @@ -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=="], diff --git a/openhands-ui/components/accordion/Accordion.stories.tsx b/openhands-ui/components/accordion/Accordion.stories.tsx new file mode 100644 index 0000000000..820a2d8827 --- /dev/null +++ b/openhands-ui/components/accordion/Accordion.stories.tsx @@ -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; + +const AccordionComponent = ({ type }: { type: AccordionProps["type"] }) => { + const [keys, { replace }] = useArray(["foo"]); + return ( +
+ + + total 30 + + + total 60 + + + total 90 + + +
+ ); +}; + +export const Multi: Story = { + render: () => , +}; + +export const Single: Story = { + render: () => , +}; diff --git a/openhands-ui/components/accordion/Accordion.tsx b/openhands-ui/components/accordion/Accordion.tsx new file mode 100644 index 0000000000..eac14e61b1 --- /dev/null +++ b/openhands-ui/components/accordion/Accordion.tsx @@ -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> & { + Item: React.FC>; +}; + +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 ( +
+ {items} +
+ ); +}; + +Accordion.Item = AccordionItem as React.FC< + PropsWithChildren +>; +export { Accordion }; diff --git a/openhands-ui/components/accordion/components/AccordionHeader.tsx b/openhands-ui/components/accordion/components/AccordionHeader.tsx new file mode 100644 index 0000000000..3cd0211916 --- /dev/null +++ b/openhands-ui/components/accordion/components/AccordionHeader.tsx @@ -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) => { + 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 ( + + ); +}; diff --git a/openhands-ui/components/accordion/components/AccordionItem.tsx b/openhands-ui/components/accordion/components/AccordionItem.tsx new file mode 100644 index 0000000000..b74cd3b28b --- /dev/null +++ b/openhands-ui/components/accordion/components/AccordionItem.tsx @@ -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) => { + return ( +
+ onExpandedChange(!expanded)} + > + {label} + + {children} +
+ ); +}; diff --git a/openhands-ui/components/accordion/components/AccordionPanel.tsx b/openhands-ui/components/accordion/components/AccordionPanel.tsx new file mode 100644 index 0000000000..0f7466b042 --- /dev/null +++ b/openhands-ui/components/accordion/components/AccordionPanel.tsx @@ -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, "aria-expanded"> & { + expanded: boolean; +}; + +export const AccordionPanel = ({ + className, + children, + expanded, + ...props +}: PropsWithChildren) => { + return ( +
+ {children} +
+ ); +}; diff --git a/openhands-ui/shared/hooks/use-array.ts b/openhands-ui/shared/hooks/use-array.ts new file mode 100644 index 0000000000..c2cf4b4f33 --- /dev/null +++ b/openhands-ui/shared/hooks/use-array.ts @@ -0,0 +1,64 @@ +import { useCallback, useState } from "react"; + +type ArrayActions = { + 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(initialValue: T | T[]): [T[], ArrayActions] { + const [array, setArray] = useState( + 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 }, + ]; +}