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

Add accordion features #518

Merged
merged 9 commits into from
Jan 7, 2025
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
5 changes: 5 additions & 0 deletions .changeset/famous-melons-ring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cypress-design/react-accordion': minor
---

Add `open`, `onClickSummary`, and ` onToggle` props and add `toggle` and `click` event listeners
2 changes: 1 addition & 1 deletion ReadMe.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ This will:
- install all dependencies if anything is missing
- build all the components, specially the ones needed for the docs
- start the docs website locally
- give you a link to open in your browser.
- give you a link to open in your browser. (http://localhost:5173/)

### Create a new component

Expand Down
51 changes: 49 additions & 2 deletions components/Accordion/assertions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ export interface AccordionStoryOptions {
open?: boolean
fullWidthContent?: boolean
headingClassName?: string
titleClassName?: string
descriptionClassName?: string
onClickSummary?: (event: MouseEvent) => boolean | undefined
onToggle?: (open: boolean) => void
}

export default function assertions(
Expand All @@ -32,12 +36,12 @@ export default function assertions(

cy.get('details summary').click()

cy.contains('Lorem ipsum, dolor sit amet').should('not.be.visible')
cy.get('details').should('not.have.attr', 'open')
})

it('displays a separator when separator:true', () => {
mountStory({ separator: true })
// the separator has a with of 1px. For some reason cypress detects it as invisible.
// the separator has a width of 1px. For some reason cypress detects it as invisible.
cy.get('[role="separator"]').should('exist')
})

Expand All @@ -60,6 +64,49 @@ export default function assertions(
cy.get('details summary').should('have.class', 'bg-gray-50')
})

it('applies the titleClassName correctly', () => {
mountStory({ titleClassName: 'text-indigo-600' })

cy.get('details summary span')
.find('.text-indigo-600')
.should('exist')
.and('have.text', 'Accordion Title')
})

it('applies the descriptionClassName correctly', () => {
mountStory({ descriptionClassName: 'text-gray-700' })

cy.get('details summary')
.find('.text-gray-700')
.should('exist')
.and(
'have.text',
'Vestibulum id ligula porta felis euismod semper. Nulla vitae elit libero, a pharetra augue. Aenean lacinia bibendum nulla.',
)
})

it('calls onClickSummary when summary is clicked', () => {
const onClickSummary = cy.stub()
mountStory({ onClickSummary })

cy.get('details summary')
.click()
.then(() => {
expect(onClickSummary).to.have.been.called
})
})

it('calls onToggle with the new state when toggled', () => {
const onToggle = cy.stub()
mountStory({ onToggle })

cy.get('details summary')
.click()
.then(() => {
expect(onToggle).to.have.been.calledWith(true)
})
})

it('should not show a separator if no icon is provided', () => {
mountStory({ separator: true, icon: null })

Expand Down
38 changes: 0 additions & 38 deletions components/Accordion/constants/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,41 +13,3 @@ export const CssClasses = {
contentWrapper: 'border border-gray-100 rounded-b-md border-t-0',
content: 'px-[24px] py-[16px]',
}

export interface AccordionProps {
/**
* Main indigo title.
* [NOTE] Its color and font can be customized using `titleClassName`.
*/
title: string
/**
* Second line in the heading.
*/
description?: string
/**
* Icon to be displayed on the left of the the heading.
*/
icon?: any
/**
* Should we add a vertical separator between the icon and the text.
*/
separator?: boolean
/**
* Change the font and color of the heading title
*/
titleClassName?: string
/**
* Change the font and color of the heading description
*/
descriptionClassName?: string
/**
* Additional classes to add to the header of the accordion
* > [NOTE] useful to change the background color of the header
*/
headingClassName?: string
/**
* When using content that needs ti be edge to edge,
* removes the content wrapper from the content.
*/
fullWidthContent?: boolean
}
8 changes: 8 additions & 0 deletions components/Accordion/react/Accordion.rootstory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ export default (options: AccordionStoryOptions = {}) => {
open = false,
fullWidthContent = false,
headingClassName,
titleClassName,
descriptionClassName,
onClickSummary,
onToggle,
...rest
} = options
return (
Expand All @@ -26,6 +30,10 @@ export default (options: AccordionStoryOptions = {}) => {
open={open}
fullWidthContent={fullWidthContent}
headingClassName={headingClassName}
titleClassName={titleClassName}
descriptionClassName={descriptionClassName}
onClickSummary={onClickSummary}
onToggle={onToggle}
{...rest}
>
<p data-cy="content">
Expand Down
120 changes: 112 additions & 8 deletions components/Accordion/react/Accordion.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import * as React from 'react'
import React, { useEffect, useState } from 'react'
import clsx from 'clsx'
import { CssClasses } from '@cypress-design/constants-accordion'
import type { AccordionProps } from '@cypress-design/constants-accordion'
import { DetailsAnimation } from '@cypress-design/details-animation'
import { IconChevronDownSmall } from '@cypress-design/react-icon'

export interface AccordionPropsReact extends AccordionProps {
export interface AccordionProps {
/**
* Main indigo title.
* [NOTE] Its color and font can be customized using `titleClassName`.
*/
title: string
/**
* Second line in the heading.
*/
description?: string | React.ReactNode
/**
* Icon to be displayed on the left of the the heading. Overridden by the iconEl prop, if both are provided.
*/
Expand All @@ -14,10 +22,42 @@ export interface AccordionPropsReact extends AccordionProps {
* Element to be displayed on the left of the the heading. Overrides the icon prop, if both are provided.
*/
iconEl?: React.ReactNode
/**
* Should we add a vertical separator between the icon and the text.
*/
separator?: boolean
/**
* Change the font and color of the heading title
*/
titleClassName?: string
/**
* Change the font and color of the heading description
*/
descriptionClassName?: string
/**
* Additional classes to add to the header of the accordion
* > [NOTE] useful to change the background color of the header
*/
headingClassName?: string
/**
* When using content that needs ti be edge to edge,
* removes the content wrapper from the content.
*/
fullWidthContent?: boolean
/**
* Provides access to the onClick event of the summary element.
* Allows for custom handling or cancellation of the default behavior.
*/
onClickSummary?: (event: MouseEvent) => boolean | undefined
/**
* Callback triggered when the accordion toggles open or closed.
* @param open - The new open state of the accordion.
*/
onToggle?: (open: boolean) => void
Copy link
Member

Choose a reason for hiding this comment

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

missing open from prop types

Copy link
Contributor Author

@karlsnyder0 karlsnyder0 Jan 7, 2025

Choose a reason for hiding this comment

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

see- React.HTMLProps<HTMLDetailsElement>. It's not missing.

}

export const Accordion: React.FC<
AccordionPropsReact & React.HTMLProps<HTMLDetailsElement>
AccordionProps & React.HTMLProps<HTMLDetailsElement>
> = ({
title,
description,
Expand All @@ -29,22 +69,82 @@ export const Accordion: React.FC<
titleClassName,
descriptionClassName,
fullWidthContent,
open,
onClickSummary,
onToggle = () => {},
...rest
}) => {
const details = React.useRef(null)
const content = React.useRef(null)
const [isOpen, setIsOpen] = useState(open)
const details = React.useRef<HTMLDetailsElement>(null)
const summary = React.useRef<HTMLDivElement>(null)
const content = React.useRef<HTMLDivElement>(null)

// Synchronize internal state with the `open` prop
useEffect(() => {
setIsOpen(open)
}, [open])

React.useEffect(() => {
if (details.current && content.current) {
new DetailsAnimation(details.current, content.current)
}
}, [])

useEffect(() => {
const summaryElement = summary?.current

if (summaryElement) {
const handleSummaryClick = (event: MouseEvent) => {
// Stop the native event
event.preventDefault()
event.stopPropagation()

let onClickRet: boolean | undefined = true
if (onClickSummary) {
onClickRet = onClickSummary(event)
}

// TODO: Clone the original event then check propagation.
Copy link
Member

Choose a reason for hiding this comment

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

Is this an open todo?

Copy link
Contributor Author

@karlsnyder0 karlsnyder0 Jan 7, 2025

Choose a reason for hiding this comment

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

It is. The ideal code would clone the original event and link stopPropagation. For now, that's not ideal for the time we have to finish, we'll keep it simple for now and it's not a blocker.

Copy link
Member

Choose a reason for hiding this comment

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

is there an issue to link to track that?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No. Actually I'm not sure we need it until we know that we will need it. In other words, it's not absolutely required but I wanted to leave a note in there in case someone wonders why stopPropagation doesn't work.

// If the callback returns false, don't continue.
if (onClickRet === false) {
return
}

const newIsOpen = !isOpen
setIsOpen(newIsOpen)

onToggle(newIsOpen)
}

summaryElement.addEventListener('click', handleSummaryClick, {
capture: true,
})

return () => {
if (summaryElement) {
summaryElement.removeEventListener('click', handleSummaryClick, {
capture: true,
})
}
}
}

return
}, [isOpen, onClickSummary, onToggle])

return (
<details {...rest} className={clsx(rest.className)} ref={details}>
<details
className={clsx(rest.className)}
ref={details}
open={isOpen}
{...rest}
>
<summary
className={clsx(
CssClasses.summary,
headingClassName ?? CssClasses.summaryColor,
)}
ref={summary}
>
<span className={CssClasses.summaryDiv}>
{Boolean(iconEl) && <span className={CssClasses.icon}>{iconEl}</span>}
Expand Down Expand Up @@ -76,7 +176,11 @@ export const Accordion: React.FC<
</span>
<IconChevronDownSmall
strokeColor="gray-300"
className={clsx('open:icon-dark-gray-500', CssClasses.chevron)}
className={clsx(
'chevron',
'open:icon-dark-gray-500',
CssClasses.chevron,
)}
/>
</span>
</summary>
Expand Down
9 changes: 8 additions & 1 deletion components/Accordion/vue/Accordion.rootstory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ export default (options: AccordionStoryOptions = {}) => {
open = false,
fullWidthContent = false,
headingClassName,
titleClassName,
descriptionClassName,
onClickSummary,
onToggle,
} = options

return (
Expand All @@ -21,10 +25,13 @@ export default (options: AccordionStoryOptions = {}) => {
separator={separator}
description={description}
icon={icon}
// @ts-expect-error volar is a little too strict for tsx html attributes. Only do this for tests
open={open}
fullWidthContent={fullWidthContent}
headingClassName={headingClassName}
titleClassName={titleClassName}
descriptionClassName={descriptionClassName}
onClickSummary={onClickSummary}
onToggle={onToggle}
>
{{
default: () => (
Expand Down
Loading
Loading