feat(accordion): impl in progress

This commit is contained in:
Junior Garcia 2023-03-17 22:21:34 -03:00
parent a3ca1e8ed8
commit 4a274014cc
23 changed files with 2035 additions and 1169 deletions

View File

@ -71,6 +71,6 @@
"babel-plugin-module-resolver": "^4.1.0",
"eslint-config-next": "^11.0.0",
"next-sitemap": "^1.6.140",
"typescript": "4.6.2"
"typescript": "^5.0.2"
}
}

View File

@ -26,7 +26,7 @@
"@types/react": "^17.0.24",
"@types/react-dom": "^17.0.9",
"eslint": "^8.11.0",
"typescript": "^4.5.5"
"typescript": "^5.0.2"
},
"engines": {
"node": ">=14"

View File

@ -15,7 +15,7 @@
"@types/react": "^17.0.33",
"@types/react-dom": "^17.0.10",
"@vitejs/plugin-react": "^1.0.7",
"typescript": "^4.5.4",
"typescript": "^5.0.2",
"vite": "^2.8.0"
}
}

View File

@ -125,7 +125,7 @@
"shelljs": "^0.8.4",
"tsup": "6.4.0",
"turbo": "1.6.3",
"typescript": "^4.7.4",
"typescript": "^5.0.2",
"uuid": "^8.3.2",
"webpack": "^5.53.0",
"webpack-bundle-analyzer": "^4.4.2",

View File

@ -0,0 +1,24 @@
# @nextui-org/accordion
A Quick description of the component
> This is an internal utility, not intended for public usage.
## Installation
```sh
yarn add @nextui-org/accordion
# or
npm i @nextui-org/accordion
```
## Contribution
Yes please! See the
[contributing guidelines](https://github.com/nextui-org/nextui/blob/master/CONTRIBUTING.md)
for details.
## Licence
This project is licensed under the terms of the
[MIT license](https://github.com/nextui-org/nextui/blob/master/LICENSE).

View File

@ -0,0 +1,19 @@
import * as React from "react";
import {render} from "@testing-library/react";
import {Accordion} from "../src";
describe("Accordion", () => {
it("should render correctly", () => {
const wrapper = render(<Accordion />);
expect(() => wrapper.unmount()).not.toThrow();
});
it("ref should be forwarded", () => {
const ref = React.createRef<HTMLDivElement>();
render(<Accordion ref={ref} />);
expect(ref.current).not.toBeNull();
});
});

View File

@ -0,0 +1,11 @@
{
"replace": {
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"types": "dist/index.d.ts",
"exports": {
".": {"import": "./dist/index.esm.js", "require": "./dist/index.cjs.js"},
"./package.json": "./package.json"
}
}
}

View File

@ -0,0 +1,72 @@
{
"name": "@nextui-org/accordion",
"version": "2.0.0-beta.1",
"description": "Collapse display a list of high-level options that can expand/collapse to reveal more information.",
"keywords": [
"react",
"accordion",
"collapse",
"display",
"list",
"expand",
"tree view"
],
"author": "Junior Garcia <jrgarciadev@gmail.com>",
"homepage": "https://nextui.org",
"license": "MIT",
"main": "src/index.ts",
"sideEffects": false,
"files": [
"dist"
],
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/nextui-org/nextui.git",
"directory": "packages/components/accordion"
},
"bugs": {
"url": "https://github.com/nextui-org/nextui/issues"
},
"scripts": {
"build": "tsup src --dts",
"build:fast": "tsup src",
"dev": "yarn build:fast -- --watch",
"clean": "rimraf dist .turbo",
"typecheck": "tsc --noEmit",
"prepack": "clean-package",
"postpack": "clean-package restore"
},
"peerDependencies": {
"react": ">=18"
},
"dependencies": {
"@nextui-org/aria-utils": "workspace:*",
"@nextui-org/dom-utils": "workspace:*",
"@nextui-org/shared-icons": "workspace:*",
"@nextui-org/shared-utils": "workspace:*",
"@nextui-org/system": "workspace:*",
"@nextui-org/theme": "workspace:*",
"@react-aria/accordion": "3.0.0-alpha.15",
"@react-aria/focus": "^3.11.0",
"@react-aria/utils": "^3.15.0",
"@react-stately/tree": "^3.5.0",
"framer-motion": "^5.6.0"
},
"devDependencies": {
"@react-types/accordion": "3.0.0-alpha.12",
"@react-types/shared": "^3.17.0",
"clean-package": "2.2.0",
"react": "^18.0.0"
},
"tsup": {
"clean": true,
"target": "es2019",
"format": [
"cjs",
"esm"
]
}
}

View File

@ -0,0 +1,10 @@
import {createContext} from "@nextui-org/shared-utils";
import {ContextType} from "./use-accordion";
export const [AccordionProvider, useAccordionContext] = createContext<ContextType>({
name: "AccordionContext",
strict: true,
errorMessage:
"useAccordionContext: `context` is undefined. Seems you forgot to wrap component within the Provider",
});

View File

@ -0,0 +1,60 @@
import {forwardRef} from "@nextui-org/system";
import {useMemo, ReactNode} from "react";
import {ChevronIcon} from "@nextui-org/shared-icons";
import {motion, AnimatePresence} from "framer-motion";
import {UseAccordionItemProps, useAccordionItem} from "./use-accordion-item";
export interface AccordionItemProps extends Omit<UseAccordionItemProps, "ref"> {}
const Accordion = forwardRef<AccordionItemProps, "div">((props, ref) => {
const {
Component,
item,
isOpen,
isDisabled,
getBaseProps,
getHeadingProps,
getButtonProps,
getTitleProps,
getSubtitleProps,
getContentProps,
getIndicatorProps,
} = useAccordionItem({ref, ...props});
const indicator = useMemo<ReactNode | null>(() => {
if (typeof item.props?.indicator === "function") {
return item.props?.indicator({indicator: <ChevronIcon />, isOpen, isDisabled});
}
return <ChevronIcon />;
}, [item.props?.indicator, isOpen, isDisabled]);
return (
<Component {...getBaseProps()}>
<h2 {...getHeadingProps()}>
<button {...getButtonProps()}>
{item.props?.title && <h3 {...getTitleProps()}>{item.props?.title}</h3>}
{item.props?.subtitle && <p {...getSubtitleProps()}>{item.props?.subtitle}</p>}
{indicator && <span {...getIndicatorProps()}>{indicator}</span>}
</button>
</h2>
<AnimatePresence>
{isOpen ? (
<motion.div
{...getContentProps()}
animate={{opacity: 1}}
exit={{opacity: 0}}
initial={{opacity: 0}}
>
{item.props?.children}
</motion.div>
) : null}
</AnimatePresence>
</Component>
);
});
Accordion.displayName = "NextUI.AccordionItem";
export default Accordion;

View File

@ -0,0 +1,32 @@
import {forwardRef} from "@nextui-org/system";
import {UseAccordionProps, useAccordion} from "./use-accordion";
import {AccordionProvider} from "./accordion-context";
import AccordionItem from "./accordion-item";
export interface AccordionProps extends Omit<UseAccordionProps, "ref"> {}
const AccordionGroup = forwardRef<AccordionProps, "div">((props, ref) => {
const {Component, context, state, getBaseProps, handleFocusChanged} = useAccordion({
ref,
...props,
});
return (
<Component {...getBaseProps()}>
<AccordionProvider value={context}>
{[...state.collection].map((item) => (
<AccordionItem
key={item.key}
item={item}
onFocusChange={(isFocused) => handleFocusChanged(isFocused, item.key)}
/>
))}
</AccordionProvider>
</Component>
);
});
AccordionGroup.displayName = "NextUI.Accordion";
export default AccordionGroup;

View File

@ -0,0 +1,42 @@
import {BaseItem, ItemProps} from "@nextui-org/aria-utils";
import {FocusableProps} from "@react-types/shared";
import {ReactNode} from "react";
export type AccordionItemIndicatorProps = {
/**
* The current indicator, usually an arrow icon.
*/
indicator?: ReactNode;
/**
* The current open status.
*/
isOpen?: boolean;
/**
* The current disabled status.
* @default false
*/
isDisabled?: boolean;
};
export interface AccordionItemBaseProps<T extends object = {}>
extends Omit<ItemProps<"button", T>, "children" | keyof FocusableProps>,
FocusableProps {
/**
* The content of the component.
*/
children?: ReactNode | null;
/**
* The accordion item subtitle.
*/
subtitle?: ReactNode | string;
/**
* The accordion item `expanded` indicator, it's usually an arrow icon.
* If you pass a function, NextUI will expose the current indicator and the open status,
* In case you want to use a custom indicator or muodify the current one.
*/
indicator?: ReactNode | ((props: AccordionItemIndicatorProps) => ReactNode) | null;
}
const AccordionItemBase = BaseItem as (props: AccordionItemBaseProps) => JSX.Element;
export default AccordionItemBase;

View File

@ -0,0 +1,18 @@
import AccordionItem from "./base/accordion-item-base";
import Accordion from "./accordion";
// export types
export type {AccordionProps} from "./accordion";
export type {AccordionItemProps} from "./accordion-item";
export type {AccordionItemIndicatorProps} from "./base/accordion-item-base";
export type {AccordionItemBaseProps} from "./base/accordion-item-base";
// export hooks
export {useAccordionItem} from "./use-accordion-item";
export {useAccordion} from "./use-accordion";
// export context
export * from "./accordion-context";
// export component
export {Accordion, AccordionItem};

View File

@ -0,0 +1,221 @@
import type {
AccordionItemVariantProps,
AccordionItemSlots,
SlotsToClasses,
} from "@nextui-org/theme";
import {HTMLNextUIProps, PropGetter} from "@nextui-org/system";
import {useFocusRing} from "@react-aria/focus";
import {accordionItem} from "@nextui-org/theme";
import {useDOMRef} from "@nextui-org/dom-utils";
import {clsx, ReactRef, callAllHandlers, dataAttr} from "@nextui-org/shared-utils";
import {NodeWithProps, useAccordionItem as useBaseAccordion} from "@nextui-org/aria-utils";
import {useCallback, useMemo} from "react";
import {mergeProps} from "@react-aria/utils";
import {AccordionItemBaseProps} from "./base/accordion-item-base";
import {useAccordionContext} from "./accordion-context";
export interface Props<T extends object> extends HTMLNextUIProps<"div"> {
/**
* Ref to the DOM node.
*/
ref?: ReactRef<HTMLButtonElement | null>;
/**
* The item node.
*/
item: NodeWithProps<T, AccordionItemBaseProps<T>>;
/**
* Classname or List of classes to change the styles of the element.
* if `className` is passed, it will be added to the base slot.
*
* @example
* ```ts
* <AccordionItem styles={{
* base:"base-classes",
* heading: "heading-classes",
* indicator: "indicator-classes",
* trigger: "trigger-classes",
* content: "content-classes",
* }} />
* ```
*/
styles?: SlotsToClasses<AccordionItemSlots>;
}
export type UseAccordionItemProps<T extends object = {}> = Props<T> &
AccordionItemVariantProps &
AccordionItemBaseProps;
export function useAccordionItem<T extends object = {}>(props: UseAccordionItemProps<T>) {
const groupContext = useAccordionContext();
const {
ref,
as,
styles,
className,
item,
size = groupContext?.size ?? "md",
radius = groupContext?.radius ?? "lg",
isDisabled: isDisabledProp = false,
disableAnimation = groupContext?.disableAnimation ?? false,
onFocusChange,
...otherProps
} = props;
const Component = as || "div";
const domRef = useDOMRef<HTMLButtonElement>(ref);
const state = groupContext?.state;
const {buttonProps: buttonCompleteProps, regionProps} = useBaseAccordion(
{item},
{...state, focusedKey: groupContext.focusedKey},
domRef,
);
const {onFocus: onFocusButton, onBlur: onBlurButton, ...buttonProps} = buttonCompleteProps;
const {isFocused, isFocusVisible, focusProps} = useFocusRing({
autoFocus: item.props?.autoFocus,
});
const isDisabled = state.disabledKeys.has(item.key) || isDisabledProp || groupContext?.isDisabled;
const isOpen = state.selectionManager.isSelected(item.key);
const handleFocus = () => {
onFocusChange?.(true);
};
const handleBlur = () => {
onFocusChange?.(false);
};
const slots = useMemo(
() =>
accordionItem({
size,
radius,
isDisabled,
isFocusVisible,
disableAnimation,
}),
[size, radius, isDisabled, isFocusVisible, disableAnimation],
);
const baseStyles = clsx(styles?.base, className);
const getBaseProps = useCallback<PropGetter>(
(props = {}) => {
return {
className: slots.base({class: baseStyles}),
...mergeProps(otherProps, props),
};
},
[baseStyles, otherProps],
);
const getButtonProps = useCallback<PropGetter>(
(props = {}) => {
return {
ref: domRef,
"data-focus-visible": dataAttr(isFocusVisible),
"data-focused": dataAttr(isFocused),
"data-disabled": dataAttr(isDisabled),
className: slots.trigger({class: styles?.trigger}),
onFocus: callAllHandlers(
handleFocus,
onFocusButton,
focusProps.onFocus,
otherProps.onFocus,
item.props?.onFocus,
),
onBlur: callAllHandlers(
handleBlur,
onBlurButton,
focusProps.onBlur,
otherProps.onBlur,
item.props?.onBlur,
),
...mergeProps(buttonProps, props),
};
},
[domRef, isFocusVisible, isFocused, isDisabled, buttonProps, focusProps, slots, styles],
);
const getContentProps = useCallback<PropGetter>(
(props = {}) => {
return {
"data-open": dataAttr(isOpen),
"data-disabled": dataAttr(isDisabled),
className: slots.content({class: styles?.content}),
...mergeProps(regionProps, props),
};
},
[slots, styles, regionProps, isOpen, isDisabled],
);
const getIndicatorProps = useCallback<PropGetter>(
(props = {}) => {
return {
"aria-hidden": dataAttr(true),
"data-open": dataAttr(isOpen),
"data-disabled": dataAttr(isDisabled),
className: slots.indicator({class: styles?.indicator}),
...props,
};
},
[slots, styles, isOpen, isDisabled],
);
const getHeadingProps = useCallback<PropGetter>(
(props = {}) => {
return {
className: slots.heading({class: styles?.heading}),
...props,
};
},
[slots, styles],
);
const getTitleProps = useCallback<PropGetter>(
(props = {}) => {
return {
className: slots.title({class: styles?.title}),
...props,
};
},
[slots, styles],
);
const getSubtitleProps = useCallback<PropGetter>(
(props = {}) => {
return {
className: slots.subtitle({class: styles?.subtitle}),
...props,
};
},
[slots, styles],
);
return {
Component,
item,
slots,
styles,
domRef,
isOpen,
isDisabled,
getBaseProps,
getHeadingProps,
getButtonProps,
getContentProps,
getIndicatorProps,
getTitleProps,
getSubtitleProps,
};
}
export type UseAccordionItemReturn = ReturnType<typeof useAccordionItem>;

View File

@ -0,0 +1,146 @@
import type {HTMLNextUIProps, PropGetter} from "@nextui-org/system";
import type {ReactRef} from "@nextui-org/shared-utils";
import type {SelectionBehavior, MultipleSelection} from "@react-types/shared";
import type {AriaAccordionProps} from "@react-types/accordion";
import {Key, useCallback} from "react";
import {TreeState, useTreeState} from "@react-stately/tree";
import {useAccordion as useReactAriaAccordion} from "@react-aria/accordion";
import {mergeProps} from "@react-aria/utils";
import {accordion} from "@nextui-org/theme";
import {useDOMRef} from "@nextui-org/dom-utils";
import {useMemo, useState} from "react";
import {AccordionItemProps} from "./accordion-item";
interface Props extends HTMLNextUIProps<"div"> {
/**
* Ref to the DOM node.
*/
ref?: ReactRef<HTMLDivElement | null>;
/**
* The accordion selection behavior.
* @default "toggle"
*/
selectionBehavior?: SelectionBehavior;
}
export type UseAccordionProps<T extends object = {}> = Props &
Pick<AccordionItemProps, "size" | "radius" | "isDisabled" | "disableAnimation"> &
AriaAccordionProps<T> &
MultipleSelection;
export type ContextType<T extends object = {}> = {
state: TreeState<T>;
focusedKey?: Key | null;
size?: AccordionItemProps["size"];
radius?: AccordionItemProps["radius"];
isDisabled?: AccordionItemProps["isDisabled"];
disableAnimation?: AccordionItemProps["disableAnimation"];
};
export function useAccordion<T extends object>(props: UseAccordionProps<T>) {
const {
ref,
as,
children,
className,
items,
expandedKeys,
defaultExpandedKeys,
disabledKeys,
selectedKeys,
selectionMode = "single",
selectionBehavior = "toggle",
disallowEmptySelection,
defaultSelectedKeys,
onExpandedChange,
onSelectionChange,
size = "md",
radius = "lg",
isDisabled = false,
disableAnimation = false,
...otherProps
} = props;
const [focusedKey, setFocusedKey] = useState<Key | null>(null);
const Component = as || "div";
const domRef = useDOMRef(ref);
const styles = useMemo(
() =>
accordion({
className,
}),
[className],
);
const commonProps = {
children,
items,
};
const expandableProps = {
expandedKeys,
defaultExpandedKeys,
onExpandedChange,
};
const treeProps = {
disabledKeys,
selectedKeys,
selectionMode,
selectionBehavior,
disallowEmptySelection,
defaultSelectedKeys: defaultSelectedKeys ?? defaultExpandedKeys,
onSelectionChange,
...commonProps,
...expandableProps,
};
const state = useTreeState(treeProps);
state.selectionManager.setFocusedKey = (key: Key | null) => {
setFocusedKey(key);
};
const {accordionProps} = useReactAriaAccordion(
{
...commonProps,
...expandableProps,
},
state,
domRef,
);
const context: ContextType<T> = useMemo(
() => ({
size,
radius,
focusedKey,
state,
isDisabled,
disableAnimation,
}),
[size, radius, state, focusedKey, isDisabled, disableAnimation],
);
const getBaseProps: PropGetter = useCallback((props = {}) => {
return {
ref: domRef,
className: styles,
"data-orientation": "vertical",
...mergeProps(accordionProps, otherProps, props),
};
}, []);
const handleFocusChanged = useCallback((isFocused: boolean, key: Key | null) => {
isFocused && setFocusedKey(key);
}, []);
return {Component, context, styles, state, focusedKey, getBaseProps, handleFocusChanged};
}
export type UseAccordionReturn = ReturnType<typeof useAccordion>;

View File

@ -0,0 +1,65 @@
import React from "react";
import {ComponentStory, ComponentMeta} from "@storybook/react";
import {accordion} from "@nextui-org/theme";
import {Accordion, AccordionProps, AccordionItem} from "../src";
export default {
title: "Components/Accordion",
component: Accordion,
argTypes: {
color: {
control: {
type: "select",
options: ["neutral", "primary", "secondary", "success", "warning", "danger"],
},
},
radius: {
control: {
type: "select",
options: ["none", "base", "sm", "md", "lg", "xl", "full"],
},
},
size: {
control: {
type: "select",
options: ["xs", "sm", "md", "lg", "xl"],
},
},
isDisabled: {
control: {
type: "boolean",
},
},
},
} as ComponentMeta<typeof Accordion>;
const defaultProps = {
...accordion.defaultVariants,
selectionMode: "single",
};
const Template: ComponentStory<typeof Accordion> = (args: AccordionProps) => (
<Accordion {...args}>
<AccordionItem key="1" title="Accordion 1">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut
labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco
laboris nisi ut aliquip ex ea commodo consequat.
</AccordionItem>
<AccordionItem key="2" title="Accordion 2">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut
labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco
laboris nisi ut aliquip ex ea commodo consequat.
</AccordionItem>
<AccordionItem key="3" title="Accordion 3">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut
labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco
laboris nisi ut aliquip ex ea commodo consequat.
</AccordionItem>
</Accordion>
);
export const Default = Template.bind({});
Default.args = {
...defaultProps,
};

View File

@ -0,0 +1,10 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"tailwind-variants": ["../../../node_modules/tailwind-variants"]
}
},
"include": ["src", "index.ts"]
}

View File

@ -0,0 +1,67 @@
import {tv, type VariantProps} from "tailwind-variants";
import {ringClasses} from "../utils";
/**
* AccordionItem wrapper **Tailwind Variants** component
*
* const {base, heading, indicator, trigger, content } = accordionItem({...})
*
* @example
* <div className={base())}>
* <div className={heading())}>
* <button className={trigger())}>Trigger</button>
* <span className={indicator())}>Indicator</span>
* </div>
* <div className={content())}>Content</div>
* </div>
*/
const accordionItem = tv({
slots: {
base: "py-2 border-b border-neutral",
heading: "",
trigger: "py-2 flex w-full outline-none items-center",
indicator: "rotate-0 data-[open=true]:-rotate-90",
title: "flex-1 text-left text-foreground",
subtitle: "",
content: "hidden data-[open=true]:block",
},
variants: {
size: {
xs: {},
sm: {},
md: {},
lg: {},
xl: {},
},
radius: {
none: {},
base: {},
sm: {},
md: {},
lg: {},
xl: {},
"2xl": {},
"3xl": {},
full: {},
},
isDisabled: {
true: {base: "opacity-50 pointer-events-none"},
},
isFocusVisible: {
true: {
trigger: [...ringClasses],
},
},
disableAnimation: {
true: {},
false: {
indicator: "transition-transform",
},
},
},
});
export type AccordionItemVariantProps = VariantProps<typeof accordionItem>;
export type AccordionItemSlots = keyof ReturnType<typeof accordionItem>;
export {accordionItem};

View File

@ -0,0 +1,20 @@
import {tv, VariantProps} from "tailwind-variants";
/**
* Accordion wrapper **Tailwind Variants** component
*
* const styles = accordion({...})
*
* @example
* <div role="group" className={styles())}>
* // avatar elements
* </div>
*/
const accordion = tv({
base: "px-2",
variants: {},
});
export type AccordionGroupVariantProps = VariantProps<typeof accordion>;
export {accordion};

View File

@ -17,3 +17,5 @@ export * from "./radio";
export * from "./radio-group";
export * from "./pagination";
export * from "./toggle";
export * from "./accordion-item";
export * from "./accordion";

View File

@ -16,6 +16,7 @@ module.exports = {
"../../components/radio/stories/*.stories.@(js|jsx|ts|tsx)",
"../../components/pagination/stories/*.stories.@(js|jsx|ts|tsx)",
"../../components/switch/stories/*.stories.@(js|jsx|ts|tsx)",
"../../components/accordion/stories/*.stories.@(js|jsx|ts|tsx)",
],
staticDirs: ["../public"],
addons: [

View File

@ -1,4 +1,5 @@
import {
useId,
useCallback,
useEffect,
KeyboardEventHandler,
@ -8,7 +9,6 @@ import {
} from "react";
import {DOMAttributes, Node, LongPressEvent, PressEvent} from "@react-types/shared";
import {focusSafely} from "@react-aria/focus";
import {useId} from "@react-aria/utils";
import {TreeState} from "@react-stately/tree";
import {useButton} from "@react-aria/button";

2374
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff