diff --git a/apps/www/src/content/docs/components/accordion/demo.ts b/apps/www/src/content/docs/components/accordion/demo.ts new file mode 100644 index 00000000..f4725dec --- /dev/null +++ b/apps/www/src/content/docs/components/accordion/demo.ts @@ -0,0 +1,161 @@ +'use client'; + +import { getPropsString } from '@/lib/utils'; + +export const getCode = (props: Record) => { + return ` + + Is it accessible? + + Yes. It adheres to the WAI-ARIA design pattern. + + + + Is it styled? + + Yes. It comes with default styles that matches the other components. + + + + Is it animated? + + Yes. It's animated by default, but you can disable it if you prefer. + + + `; +}; + +export const playground = { + type: 'playground', + controls: { + type: { + type: 'select', + options: ['single', 'multiple'], + defaultValue: 'single' + } + }, + getCode +}; + +export const typeDemo = { + type: 'code', + tabs: [ + { + name: 'Single', + code: ` + + + What is Apsara? + + Apsara is a modern design system and component library built with React and TypeScript. + + + + How do I get started? + + You can install Apsara using your preferred package manager and start building your application. + + + + Is it customizable? + + Yes, Apsara provides extensive customization options through CSS variables and component props. + + +` + }, + { + name: 'Multiple', + code: ` + + + What is Apsara? + + Apsara is a modern design system and component library built with React and TypeScript. + + + + How do I get started? + + You can install Apsara using your preferred package manager and start building your application. + + + + Is it customizable? + + Yes, Apsara provides extensive customization options through CSS variables and component props. + + +` + } + ] +}; +export const controlledDemo = { + type: 'code', + code: ` + function ControlledAccordion() { + const [value, setValue] = React.useState('item-1'); + + return ( + + + Item 1 + Content for item 1 + + + Item 2 + Content for item 2 + + + ); +}` +}; + +export const disabledDemo = { + type: 'code', + code: ` + + + Enabled Item + This item is enabled and can be toggled. + + + Disabled Item + This item is disabled and cannot be toggled. + + + Another Enabled Item + This item is also enabled. + +` +}; + +export const customContentDemo = { + type: 'code', + code: ` + + + Product Features + +
+

Key Features

+
    +
  • Responsive design
  • +
  • Accessible components
  • +
  • TypeScript support
  • +
  • Customizable themes
  • +
+
+
+
+ + Documentation + +
+

Comprehensive documentation with examples and API references.

+ View Documentation +
+
+
+
` +}; diff --git a/apps/www/src/content/docs/components/accordion/index.mdx b/apps/www/src/content/docs/components/accordion/index.mdx new file mode 100644 index 00000000..6faca9b2 --- /dev/null +++ b/apps/www/src/content/docs/components/accordion/index.mdx @@ -0,0 +1,83 @@ +--- +title: Accordion +description: A vertically stacked set of interactive headings that each reveal a section of content. +tag: new +--- + +import { + playground, + typeDemo, + controlledDemo, + disabledDemo, + customContentDemo, +} from "./demo.ts"; + + + +## Usage + +```tsx +import { Accordion } from '@raystack/apsara' +``` + +## Accordion Props + + + +### Accordion.Item Props + + + +### Accordion.Trigger Props + + + +### Accordion.Content Props + + + +## Examples + +### Single vs Multiple + +The Accordion component supports two types of behavior: + +- **Single**: Only one item can be open at a time +- **Multiple**: Multiple items can be open simultaneously + + + +### Controlled + +You can control the accordion's state by providing `value` and `onValueChange` props. + + + +### Disabled Items + +Individual accordion items can be disabled using the `disabled` prop. + + + +### Custom Content + +The accordion content can contain any React elements, allowing for rich layouts and complex content. + + + +## Accessibility + +The Accordion component is built on top of [Radix UI's Accordion primitive](https://www.radix-ui.com/primitives/docs/components/accordion) and follows the WAI-ARIA design pattern for accordions. It includes: + +- Proper ARIA attributes for screen readers +- Keyboard navigation support +- Focus management +- Semantic HTML structure + +## Keyboard Navigation + +- **Space** or **Enter**: Toggle the focused accordion item +- **Arrow Down**: Move focus to the next accordion item +- **Arrow Up**: Move focus to the previous accordion item +- **Home**: Move focus to the first accordion item +- **End**: Move focus to the last accordion item diff --git a/apps/www/src/content/docs/components/accordion/props.ts b/apps/www/src/content/docs/components/accordion/props.ts new file mode 100644 index 00000000..a520ea8d --- /dev/null +++ b/apps/www/src/content/docs/components/accordion/props.ts @@ -0,0 +1,83 @@ +export interface AccordionRootProps { + /** + * Controls how many accordion items can be open at once. + * - "single": Only one item can be open at a time + * - "multiple": Multiple items can be open simultaneously + * @defaultValue "single" + */ + type?: 'single' | 'multiple'; + + /** + * The controlled value of the accordion + */ + value?: string | string[]; + + /** + * The default value of the accordion + */ + defaultValue?: string | string[]; + + /** + * Event handler called when the value changes + */ + onValueChange?: (value: string | string[]) => void; + + /** + * Whether the accordion is collapsible when type is single + * @defaultValue true + */ + collapsible?: boolean; + + /** + * Whether the accordion is disabled + * @defaultValue false + */ + disabled?: boolean; + + /** + * The orientation of the accordion + * @defaultValue "vertical" + */ + orientation?: 'horizontal' | 'vertical'; + + /** + * The direction of the accordion + * @defaultValue "ltr" + */ + dir?: 'ltr' | 'rtl'; + + /** Custom CSS class names */ + className?: string; +} + +export interface AccordionItemProps { + /** + * A unique value for the item + */ + value: string; + + /** + * Whether the item is disabled + * @defaultValue false + */ + disabled?: boolean; + + /** Custom CSS class names */ + className?: string; +} + +export interface AccordionTriggerProps { + /** Custom CSS class names */ + className?: string; +} + +export interface AccordionContentProps { + /** + * Whether the content is force mounted + * @defaultValue false + */ + forceMount?: boolean; + + /** Custom CSS class names */ + className?: string; +} diff --git a/packages/raystack/components/accordion/__tests__/accordion.test.tsx b/packages/raystack/components/accordion/__tests__/accordion.test.tsx new file mode 100644 index 00000000..d1bbaa8a --- /dev/null +++ b/packages/raystack/components/accordion/__tests__/accordion.test.tsx @@ -0,0 +1,239 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { Accordion } from '../accordion'; +import { AccordionRootProps } from '../accordion-root'; +import styles from '../accordion.module.css'; + +const ITEM_1_TEXT = 'Item 1'; +const ITEM_2_TEXT = 'Item 2'; +const CONTENT_1_TEXT = 'Content 1'; +const CONTENT_2_TEXT = 'Content 2'; + +const BasicAccordion = ({ + hasDisabledItem = false, + children, + ...props +}: AccordionRootProps & { hasDisabledItem?: boolean }) => { + return ( + + + {ITEM_1_TEXT} + {CONTENT_1_TEXT} + + + {ITEM_2_TEXT} + {CONTENT_2_TEXT} + + {children} + + ); +}; + +describe('Accordion', () => { + describe('Basic Rendering', () => { + it('renders accordion with children', () => { + render(); + + expect( + screen.getByRole('button', { name: ITEM_1_TEXT }) + ).toBeInTheDocument(); + expect(screen.getByText(CONTENT_1_TEXT)).toBeInTheDocument(); + }); + + it('renders multiple accordion items', () => { + render(); + + expect( + screen.getByRole('button', { name: ITEM_1_TEXT }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: ITEM_2_TEXT }) + ).toBeInTheDocument(); + }); + + it('applies custom className to accordion', () => { + render( + + ); + + const accordion = screen.getByTestId('custom'); + expect(accordion).toHaveClass('custom-accordion'); + }); + + it('forwards ref correctly', () => { + const ref = vi.fn(); + render( + + + {ITEM_1_TEXT} + {CONTENT_1_TEXT} + + + ); + expect(ref).toHaveBeenCalled(); + }); + + it('expands and collapses content on trigger click', () => { + render(); + + const trigger = screen.getByRole('button', { name: ITEM_1_TEXT }); + + // Initially closed + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + + // Click to open + fireEvent.click(trigger); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + + // Click to close + fireEvent.click(trigger); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + }); + + it('supports keyboard navigation', async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByRole('button', { name: ITEM_2_TEXT }); + + await user.keyboard('{Tab}{ArrowDown}{Enter}'); + + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + }); + }); + + describe('AccordionItem', () => { + it('forwards ref correctly', () => { + const ref = vi.fn(); + render( + + + {ITEM_1_TEXT} + {CONTENT_1_TEXT} + + + ); + expect(ref).toHaveBeenCalled(); + }); + }); + + describe('AccordionTrigger', () => { + it('renders with custom className', () => { + render( + + + + {ITEM_1_TEXT} + + {CONTENT_1_TEXT} + + + ); + + const trigger = screen.getByTestId('custom-trigger'); + expect(trigger).toHaveClass('custom-trigger'); + }); + + it('renders with chevron icon', () => { + render(); + + const icon = screen + .getByRole('button', { name: ITEM_1_TEXT }) + .querySelector('svg'); + expect(icon).toBeInTheDocument(); + expect(icon).toHaveClass(styles['accordion-icon']); + }); + + it('forwards ref correctly', () => { + const ref = vi.fn(); + render( + + + {ITEM_1_TEXT} + {CONTENT_1_TEXT} + + + ); + expect(ref).toHaveBeenCalled(); + }); + + it('handles click events', () => { + const handleClick = vi.fn(); + render( + + + + {ITEM_1_TEXT} + + {CONTENT_1_TEXT} + + + ); + + const trigger = screen.getByRole('button', { name: ITEM_1_TEXT }); + fireEvent.click(trigger); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('handles disabled state', () => { + render(); + + const trigger = screen.getByRole('button', { name: ITEM_2_TEXT }); + expect(trigger).toBeDisabled(); + }); + }); + + describe('AccordionContent', () => { + it('renders with correct className', () => { + render( + + + {ITEM_1_TEXT} + + {CONTENT_1_TEXT} + + + + ); + + const content = screen.getByText(CONTENT_1_TEXT).closest('div'); + expect(content).toHaveClass('custom-content'); + }); + + it('forwards ref correctly', () => { + const ref = vi.fn(); + render( + + + {ITEM_1_TEXT} + {CONTENT_1_TEXT} + + + ); + expect(ref).toHaveBeenCalled(); + }); + }); + + describe('Multiple Items', () => { + it('handles multiple accordion items independently', () => { + render(); + + const trigger1 = screen.getByRole('button', { name: ITEM_1_TEXT }); + const trigger2 = screen.getByRole('button', { name: ITEM_2_TEXT }); + + // Open first item + fireEvent.click(trigger1); + expect(trigger1).toHaveAttribute('aria-expanded', 'true'); + expect(trigger2).toHaveAttribute('aria-expanded', 'false'); + + // Open second item + fireEvent.click(trigger2); + expect(trigger1).toHaveAttribute('aria-expanded', 'true'); + expect(trigger2).toHaveAttribute('aria-expanded', 'true'); + }); + }); +}); diff --git a/packages/raystack/components/accordion/accordion-content.tsx b/packages/raystack/components/accordion/accordion-content.tsx new file mode 100644 index 00000000..794697ff --- /dev/null +++ b/packages/raystack/components/accordion/accordion-content.tsx @@ -0,0 +1,29 @@ +'use client'; + +import { cx } from 'class-variance-authority'; +import { Accordion as AccordionPrimitive } from 'radix-ui'; +import { ElementRef, ReactNode, forwardRef } from 'react'; +import styles from './accordion.module.css'; + +export interface AccordionContentProps + extends AccordionPrimitive.AccordionContentProps { + children: ReactNode; + className?: string; +} + +export const AccordionContent = forwardRef< + ElementRef, + AccordionContentProps +>(({ className, children, ...props }, ref) => ( + +
+ {children} +
+
+)); + +AccordionContent.displayName = AccordionPrimitive.Content.displayName; diff --git a/packages/raystack/components/accordion/accordion-item.tsx b/packages/raystack/components/accordion/accordion-item.tsx new file mode 100644 index 00000000..58af12e6 --- /dev/null +++ b/packages/raystack/components/accordion/accordion-item.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { cx } from 'class-variance-authority'; +import { Accordion as AccordionPrimitive } from 'radix-ui'; +import { ElementRef, ReactNode, forwardRef } from 'react'; +import styles from './accordion.module.css'; + +export interface AccordionItemProps + extends AccordionPrimitive.AccordionItemProps { + children: ReactNode; + className?: string; +} + +export const AccordionItem = forwardRef< + ElementRef, + AccordionItemProps +>(({ className, children, ...props }, ref) => ( + + {children} + +)); + +AccordionItem.displayName = AccordionPrimitive.Item.displayName; diff --git a/packages/raystack/components/accordion/accordion-root.tsx b/packages/raystack/components/accordion/accordion-root.tsx new file mode 100644 index 00000000..75d5bfbc --- /dev/null +++ b/packages/raystack/components/accordion/accordion-root.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { cx } from 'class-variance-authority'; +import { Accordion as AccordionPrimitive } from 'radix-ui'; +import { ElementRef, forwardRef } from 'react'; +import styles from './accordion.module.css'; + +type AccordionSingleProps = Omit< + AccordionPrimitive.AccordionSingleProps, + 'type' +> & { + type?: 'single'; +}; +type AccordionMultipleProps = Omit< + AccordionPrimitive.AccordionMultipleProps, + 'type' +> & { + type: 'multiple'; +}; +export type AccordionRootProps = AccordionSingleProps | AccordionMultipleProps; + +export const AccordionRoot = forwardRef< + ElementRef, + AccordionRootProps +>(({ className, type = 'single', ...rest }, ref) => { + // this is a workaround to properly typecast the union type + const singleProps = { + type: 'single', + collapsible: true, + ...rest + } as AccordionPrimitive.AccordionSingleProps; + const multipleProps = { + type: 'multiple', + ...rest + } as AccordionPrimitive.AccordionMultipleProps; + + return ( + + ); +}); + +AccordionRoot.displayName = AccordionPrimitive.Root.displayName; diff --git a/packages/raystack/components/accordion/accordion-trigger.tsx b/packages/raystack/components/accordion/accordion-trigger.tsx new file mode 100644 index 00000000..997fff59 --- /dev/null +++ b/packages/raystack/components/accordion/accordion-trigger.tsx @@ -0,0 +1,31 @@ +'use client'; + +import { ChevronDownIcon } from '@radix-ui/react-icons'; +import { cx } from 'class-variance-authority'; +import { Accordion as AccordionPrimitive } from 'radix-ui'; +import { ElementRef, ReactNode, forwardRef } from 'react'; +import styles from './accordion.module.css'; + +export interface AccordionTriggerProps + extends AccordionPrimitive.AccordionTriggerProps { + children: ReactNode; + className?: string; +} + +export const AccordionTrigger = forwardRef< + ElementRef, + AccordionTriggerProps +>(({ className, children, ...props }, ref) => ( + + + {children} + + + +)); + +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; diff --git a/packages/raystack/components/accordion/accordion.module.css b/packages/raystack/components/accordion/accordion.module.css new file mode 100644 index 00000000..28eb4b7c --- /dev/null +++ b/packages/raystack/components/accordion/accordion.module.css @@ -0,0 +1,99 @@ +.accordion { + width: 100%; + color: var(--rs-color-foreground-base-primary); +} + +.accordion-header { + display: flex; +} + +.accordion-trigger { + display: flex; + min-height: 36px; + padding: 0 var(--rs-space-4); + justify-content: space-between; + align-items: center; + gap: var(--rs-space-5); + flex-shrink: 0; + flex: 1; + border: 0.5px solid var(--rs-color-border-base-primary); + background: var(--rs-color-background-base-primary); + cursor: pointer; + text-align: left; + outline: none; + font-size: var(--rs-font-size-regular); + line-height: var(--rs-line-height-regular); + letter-spacing: var(--rs-letter-spacing-regular); +} +.accordion-item:not(:first-child) .accordion-trigger { + border-top: none; +} + +.accordion-trigger:hover, +.accordion-trigger:focus-visible { + background-color: var(--rs-color-background-base-primary-hover); +} + +.accordion-trigger:disabled { + pointer-events: none; + opacity: 0.5; +} + +.accordion-trigger[data-state="open"] .accordion-icon { + transform: rotate(180deg); +} + +.accordion-icon { + color: var(--rs-color-foreground-base-secondary); + pointer-events: none; + flex-shrink: 0; + transition: transform 150ms ease-in-out; + width: var(--rs-space-4); + height: var(--rs-space-4); +} + +.accordion-content { + overflow: hidden; +} + +.accordion-content-inner { + border: 0.5px solid var(--rs-color-border-base-primary); + background: var(--rs-color-background-base-primary); + box-shadow: var(--rs-shadow-inset); + font-size: var(--rs-font-size-small); + line-height: var(--rs-line-height-small); + letter-spacing: var(--rs-letter-spacing-small); + display: flex; + align-items: center; + gap: var(--rs-space-5); + align-self: stretch; + padding: var(--rs-space-5) var(--rs-space-4); + border-top: 0px; +} + +.accordion-content[data-state="closed"] { + animation: accordion-up 200ms ease-out; +} + +.accordion-content[data-state="open"] { + animation: accordion-down 200ms ease-out; +} + +/* Animations */ +@keyframes accordion-down { + from { + height: 0; + } + to { + height: var(--radix-accordion-content-height); + } +} + +@keyframes accordion-up { + from { + height: var(--radix-accordion-content-height); + } + to { + height: 0; + } +} diff --git a/packages/raystack/components/accordion/accordion.tsx b/packages/raystack/components/accordion/accordion.tsx new file mode 100644 index 00000000..e4c0dea4 --- /dev/null +++ b/packages/raystack/components/accordion/accordion.tsx @@ -0,0 +1,10 @@ +import { AccordionContent } from './accordion-content'; +import { AccordionItem } from './accordion-item'; +import { AccordionRoot } from './accordion-root'; +import { AccordionTrigger } from './accordion-trigger'; + +export const Accordion = Object.assign(AccordionRoot, { + Item: AccordionItem, + Trigger: AccordionTrigger, + Content: AccordionContent +}); diff --git a/packages/raystack/components/accordion/index.tsx b/packages/raystack/components/accordion/index.tsx new file mode 100644 index 00000000..f7ea0e91 --- /dev/null +++ b/packages/raystack/components/accordion/index.tsx @@ -0,0 +1 @@ +export { Accordion } from './accordion'; diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index e3ad71ae..481bab61 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -1,6 +1,7 @@ import './styles/index.css'; import './normalize.css'; +export { Accordion } from './components/accordion'; export { Amount } from './components/amount'; export { AnnouncementBar } from './components/announcement-bar'; export { Avatar, AvatarGroup, getAvatarColor } from './components/avatar';