Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WEB-3926] feat: port underlying Accordion to radix, add product icons, menu styles, bump to 14.8.0 #537

Merged
merged 2 commits into from
Nov 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ably/ui",
"version": "14.7.9",
"version": "14.8.0",
"description": "Home of the Ably design system library ([design.ably.com](https://design.ably.com)). It provides a showcase, development/test environment and a publishing pipeline for different distributables.",
"repository": {
"type": "git",
Expand Down Expand Up @@ -74,8 +74,10 @@
"test:update-snapshots": "npx concurrently -k -s first -n \"SB,TEST\" -c \"magenta,blue\" \"yarn build-storybook && yarn http-server preview --port 6007 --silent\" \"wait-on tcp:6007 && yarn test-storybook -u --url http://127.0.0.1:6007\""
},
"dependencies": {
"@radix-ui/react-accordion": "^1.2.1",
"addsearch-js-client": "^0.8.11",
"array-flat-polyfill": "^1.0.1",
"clsx": "^2.1.1",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice, I forgot about this package. and it's even in Voltaire already. will bear in mind to use this next time :D

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I forgot about it as well! Much cleaner than doing loads string interpolation as we have been doing elsewhere (and as you say we already have this package elsewhere). From what I can see clsx and classnames are basically interchangeable, so we need one or other other (though I understand that clsx is just the same but lighter)

"dompurify": "^3.1.4",
"highlight.js": "^11.9.0",
"highlightjs-curl": "^1.3.0",
Expand Down
278 changes: 129 additions & 149 deletions src/core/Accordion.tsx
Original file line number Diff line number Diff line change
@@ -1,220 +1,200 @@
import React, { useState, ReactNode, useRef, useEffect } from "react";
import React, { ReactNode, useMemo, useState } from "react";
import {
AccordionContent,
AccordionItem,
AccordionTrigger,
Accordion as RadixAccordion,
} from "@radix-ui/react-accordion";
import clsx from "clsx";

import Icon from "./Icon";
import type { IconName } from "./Icon/types";
import type { ColorClass } from "./styles/colors/types";
import type {
AccordionData,
AccordionIcons,
AccordionOptions,
AccordionTheme,
AccordionThemeColors,
} from "./Accordion/types";
import {
themeClasses,
isNonTransparentTheme,
isStaticTheme,
} from "./Accordion/utils";

type AccordionRowProps = {
children: ReactNode;
name: string;
onClick: () => void;
open: boolean;
rowIcon?: IconName;
theme: AccordionTheme;
toggleIcons: AccordionIcons;
options?: AccordionOptions;
index: number;
onClick: () => void;
openRowValues: string | string[];
};

export type AccordionProps = {
className?: string;
/**
* The data for the accordion items.
*/
data: AccordionData[];
icons?: AccordionIcons;
id?: string;
theme?: AccordionTheme;
options?: AccordionOptions;
};

const themeClasses: Record<AccordionTheme, AccordionThemeColors> = {
dark: {
bg: "bg-neutral-1200",
hoverBg: "hover:bg-neutral-1100",
text: "text-white",
toggleIconColor: "text-orange-600",
selectableBg: "bg-neutral-300",
selectableText: "text-neutral-1300",
},
light: {
bg: "bg-neutral-200",
hoverBg: "hover:bg-neutral-300",
text: "text-neutral-1300",
toggleIconColor: "text-neutral-1000",
selectableBg: "bg-neutral-1200",
selectableText: "text-white",
},
transparent: {
bg: "bg-transparent",
hoverBg: "hover:bg-transparent",
text: "text-neutral-1000",
toggleIconColor: "text-dark-grey",
border: "border-neutral-500 border-b last:border-none",
},
darkTransparent: {
bg: "bg-transparent",
hoverBg: "hover:bg-transparent",
text: "text-neutral-000",
toggleIconColor: "text-orange-600",
border: "border-neutral-900 border-b last:border-none",
},
static: {
bg: "bg-neutral-200",
hoverBg: "hover:bg-neutral-200",
text: "text-neutral-1300",
toggleIconColor: "text-neutral-200",
selectableBg: "bg-neutral-1200",
selectableText: "text-white",
},
darkStatic: {
bg: "bg-neutral-1200",
hoverBg: "hover:bg-neutral-1200",
text: "text-white",
toggleIconColor: "text-neutral-1200",
selectableBg: "bg-neutral-1200",
selectableText: "text-neutral-1300",
},
};
/**
* Icons for the accordion toggle.
*/
icons?: AccordionIcons;

const isNonTransparentTheme = (theme: AccordionTheme) =>
!["transparent", "darkTransparent"].includes(theme);
/**
* Theme for the accordion.
*/
theme?: AccordionTheme;

const isStaticTheme = (theme: AccordionTheme) =>
["static", "darkStatic"].includes(theme);
/**
* Options for the accordion behavior.
*/
options?: AccordionOptions;
} & React.HTMLAttributes<HTMLDivElement>;

const AccordionRow = ({
name,
children,
onClick,
open,
rowIcon,
options,
toggleIcons,
theme,
index,
onClick,
openRowValues,
}: AccordionRowProps) => {
const rowRef = useRef<HTMLDivElement>(null);

const [contentHeight, setContentHeight] = useState<number>(0);

useEffect(() => {
const resizeObserver = new ResizeObserver(() => {
if (rowRef.current) {
setContentHeight(rowRef.current.scrollHeight + 16);
}
});

if (rowRef.current) {
resizeObserver.observe(rowRef.current);
}

return () => {
if (rowRef.current) {
resizeObserver.unobserve(rowRef.current);
}
};
}, []);

const { selectable, sticky } = options || {};
const rowKey = `accordion-item-${index}`;
const isOpen = openRowValues.includes(rowKey);

const {
text,
bg,
hoverBg,
toggleIconColor,
selectableBg,
selectableText,
border,
toggleIconColor,
} = themeClasses[theme];

const bgClasses: string =
(selectable && open && selectableBg) || `${bg} ${hoverBg}`;

const textClass: ColorClass = (selectable && open && selectableText) || text;
const textClass = (selectable && isOpen && selectableText) || text;

return (
<div className={`${border ?? ""}`}>
<button
type="button"
{...(!isStaticTheme(theme) ? { onClick } : {})}
className={`flex w-full ${sticky ? "sticky top-0" : ""} focus:outline-none py-16 rounded-lg ui-text-p1 font-bold text-left items-center gap-12 ${isNonTransparentTheme(theme) ? "px-16 mb-16" : ""} ${isStaticTheme(theme) ? "pointer-events-none" : ""} transition-colors ${bgClasses} ${textClass}`}
<AccordionItem
value={rowKey}
className={clsx({
[`${border}`]: border && !options?.hideBorders,
})}
>
<AccordionTrigger
onClick={onClick}
className={clsx({
"flex w-full group/accordion-trigger py-16 ui-text-p1 font-bold text-left items-center gap-12 transition-colors":
true,
"px-16 mb-16 rounded-lg": isNonTransparentTheme(theme),
"rounded-none": !isNonTransparentTheme(theme),
"pointer-events-none focus:outline-none": isStaticTheme(theme),
"focus:outline-gui-blue-focus": !isStaticTheme(theme),
"sticky top-0": sticky,
[`${bg} ${hoverBg} ${text}`]: !(selectable && isOpen),
[`${selectableBg} ${selectableText}`]: selectable && isOpen,
[options?.headerCSS ?? ""]: options?.headerCSS,
[options?.selectedHeaderCSS ?? ""]:
options?.selectedHeaderCSS && isOpen,
})}
>
{rowIcon ? <Icon name={rowIcon} color={textClass} size="32px" /> : null}
{rowIcon ? (
<Icon
name={rowIcon}
color={textClass}
size={options?.rowIconSize ?? "32px"}
/>
) : null}
<span>{name}</span>
{!selectable ? (
{!selectable && !isStaticTheme(theme) ? (
<span className="flex-1 justify-end flex items-center">
<Icon
name={open ? toggleIcons.open.name : toggleIcons.closed.name}
name={isOpen ? toggleIcons.open.name : toggleIcons.closed.name}
color={toggleIconColor}
size="16px"
/>{" "}
size={options?.iconSize ?? "16px"}
/>
</span>
) : null}
</button>
<div
className={`ui-text-p2 transition-[max-height] duration-500 overflow-y-hidden`}
style={{ maxHeight: open ? contentHeight : 0 }}
ref={rowRef}
</AccordionTrigger>
<AccordionContent
className={clsx({
"ui-text-p2 overflow-hidden transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down":
true,
[options?.contentCSS ?? ""]: options?.contentCSS,
})}
>
<div className="pb-16">{children}</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
);
};

const Accordion = ({
data,
theme = "transparent",
id = "id-accordion",
className = "",
icons = {
closed: { name: "icon-gui-plus" },
open: { name: "icon-gui-minus" },
},
options,
...props
}: AccordionProps) => {
const { defaultOpenIndexes, autoClose, fullyOpen } = options || {};
const [openIndexes, setOpenIndexes] = useState<number[]>(
defaultOpenIndexes ?? [],
);

const handleSetIndex = (index: number) => {
const currentIndexIsOpen = openIndexes.includes(index);

if (autoClose) {
setOpenIndexes(currentIndexIsOpen ? [] : [index]);
} else {
setOpenIndexes(
currentIndexIsOpen
? openIndexes.filter((i) => i !== index)
: [...openIndexes, index],
);
}
};
const [openRowValues, setOpenRowValues] = useState<string | string[]>([]);
const innerAccordion = data.map((item, index) => (
<AccordionRow
key={item.name}
name={item.name}
rowIcon={item.icon}
jamiehenson marked this conversation as resolved.
Show resolved Hide resolved
toggleIcons={icons}
theme={theme}
options={options}
index={index}
onClick={() => {
item.onClick?.(index);
}}
openRowValues={openRowValues}
>
{item.content}
</AccordionRow>
));

const openIndexes = useMemo(() => {
const indexValues = data.map((_, i) => `accordion-item-${i}`);
return options?.fullyOpen
? indexValues
: indexValues.filter((_, index) =>
options?.defaultOpenIndexes?.includes(index),
);
}, [options?.defaultOpenIndexes, options?.fullyOpen, data.length]);

return (
<div className={className} id={id}>
{data.map((item, currentIndex) => {
return (
<AccordionRow
key={item.name}
name={item.name}
rowIcon={item.icon}
open={fullyOpen ?? openIndexes.includes(currentIndex)}
onClick={() => {
handleSetIndex(currentIndex);
item.onClick?.(currentIndex);
}}
toggleIcons={icons}
theme={theme}
options={options}
>
{item.content}
</AccordionRow>
);
})}
<div {...props}>
{options?.autoClose ? (
<RadixAccordion
type="single"
collapsible
defaultValue={openIndexes[0]}
onValueChange={(values) => setOpenRowValues(values)}
>
{innerAccordion}
</RadixAccordion>
) : (
<RadixAccordion
type="multiple"
defaultValue={openIndexes}
onValueChange={(values) => setOpenRowValues(values)}
>
{innerAccordion}
</RadixAccordion>
)}
</div>
jamiehenson marked this conversation as resolved.
Show resolved Hide resolved
);
};
Expand Down
Loading
Loading