diff --git a/package-lock.json b/package-lock.json index 833be91f37..4f6a68a8e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8039,6 +8039,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@spark-ui/accordion": { + "resolved": "packages/components/accordion", + "link": true + }, "node_modules/@spark-ui/alert-dialog": { "resolved": "packages/components/alert-dialog", "link": true @@ -13600,6 +13604,59 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "dev": true }, + "node_modules/@zag-js/accordion": { + "version": "0.56.1", + "resolved": "https://registry.npmjs.org/@zag-js/accordion/-/accordion-0.56.1.tgz", + "integrity": "sha512-ylaVNTdqf5sORTUXK+9bg57fLrpA7eZkhuffPRgqw+1Di/VRCRnLjFpd0VBdzkVKvgurUF/YyewNCouVsW8TjQ==", + "dependencies": { + "@zag-js/anatomy": "0.56.1", + "@zag-js/core": "0.56.1", + "@zag-js/dom-event": "0.56.1", + "@zag-js/dom-query": "0.56.1", + "@zag-js/types": "0.56.1", + "@zag-js/utils": "0.56.1" + } + }, + "node_modules/@zag-js/accordion/node_modules/@zag-js/anatomy": { + "version": "0.56.1", + "resolved": "https://registry.npmjs.org/@zag-js/anatomy/-/anatomy-0.56.1.tgz", + "integrity": "sha512-4n7WKZ2YEYIsKSV7rEZcx6pGKhIJ6EHSn0KrC8BNI+0roJfYCfQbggih2f4E3JFT29pUFaSXIpRJa7XYyClQMw==" + }, + "node_modules/@zag-js/accordion/node_modules/@zag-js/core": { + "version": "0.56.1", + "resolved": "https://registry.npmjs.org/@zag-js/core/-/core-0.56.1.tgz", + "integrity": "sha512-3Iwum6+UmDxBqJIT4t2wSnr5hz83c6oRDS8j7I0hgV4U2Xt/IJMPedZt/KmV8ghjai/BlETskHUW1JvwZ4jaYA==", + "dependencies": { + "@zag-js/store": "0.56.1", + "klona": "2.0.6" + } + }, + "node_modules/@zag-js/accordion/node_modules/@zag-js/dom-query": { + "version": "0.56.1", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-0.56.1.tgz", + "integrity": "sha512-mxUa7vzI+NhaMpf0D3cci0kmRuCPBkZCvoV+jsokkLfFKEUvAMVftnzxMxeydAEK6LFZL6PK6icOXP5FxbzzLA==" + }, + "node_modules/@zag-js/accordion/node_modules/@zag-js/store": { + "version": "0.56.1", + "resolved": "https://registry.npmjs.org/@zag-js/store/-/store-0.56.1.tgz", + "integrity": "sha512-hc7lwqhor+WZXXXuCEV889QEHX7hBpTN0NUS+pHa8fED7QeJN+EhJZMwC3g6YLVde4w3MlxB6I92O5lruNNyEg==", + "dependencies": { + "proxy-compare": "3.0.0" + } + }, + "node_modules/@zag-js/accordion/node_modules/@zag-js/types": { + "version": "0.56.1", + "resolved": "https://registry.npmjs.org/@zag-js/types/-/types-0.56.1.tgz", + "integrity": "sha512-RkAO2PInKWo6oQmrFkwXpWO1VitL0jPs8D30r66/6+lSAEGLnCUifnkZchluJ8vlHvr04Q5emzFkIG4fQHLLmw==", + "dependencies": { + "csstype": "3.1.3" + } + }, + "node_modules/@zag-js/accordion/node_modules/@zag-js/utils": { + "version": "0.56.1", + "resolved": "https://registry.npmjs.org/@zag-js/utils/-/utils-0.56.1.tgz", + "integrity": "sha512-zJ1HxV+26I6Uu0M112ybEJUsHXlHQ2bE9XuDX+n7uDzPbilMvLQ87XYhJ40bb2lykgNsKCWhLWV9xkiEVJD38w==" + }, "node_modules/@zag-js/anatomy": { "version": "0.56.0", "resolved": "https://registry.npmjs.org/@zag-js/anatomy/-/anatomy-0.56.0.tgz", @@ -13626,19 +13683,42 @@ "klona": "2.0.6" } }, + "node_modules/@zag-js/dom-event": { + "version": "0.56.1", + "resolved": "https://registry.npmjs.org/@zag-js/dom-event/-/dom-event-0.56.1.tgz", + "integrity": "sha512-OUh6ELiN6RIkSFt6pQ+Ct7jdH5XoonBThBBLUKoUd5qr49bIUw+CIaJyVrngUaPSCTqpzIqS3lL3uvVbMN0W+g==", + "dependencies": { + "@zag-js/dom-query": "0.56.1", + "@zag-js/text-selection": "0.56.1", + "@zag-js/types": "0.56.1" + } + }, + "node_modules/@zag-js/dom-event/node_modules/@zag-js/dom-query": { + "version": "0.56.1", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-0.56.1.tgz", + "integrity": "sha512-mxUa7vzI+NhaMpf0D3cci0kmRuCPBkZCvoV+jsokkLfFKEUvAMVftnzxMxeydAEK6LFZL6PK6icOXP5FxbzzLA==" + }, + "node_modules/@zag-js/dom-event/node_modules/@zag-js/types": { + "version": "0.56.1", + "resolved": "https://registry.npmjs.org/@zag-js/types/-/types-0.56.1.tgz", + "integrity": "sha512-RkAO2PInKWo6oQmrFkwXpWO1VitL0jPs8D30r66/6+lSAEGLnCUifnkZchluJ8vlHvr04Q5emzFkIG4fQHLLmw==", + "dependencies": { + "csstype": "3.1.3" + } + }, "node_modules/@zag-js/dom-query": { "version": "0.56.0", "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-0.56.0.tgz", "integrity": "sha512-KE0Aes0Ov23pHddD3tm5sqzzd7PLMjZ0VbzBua16J+cdut+oiCtzPURVkD8NmviTo3/n02RN/wtvg65V0Yhusw==" }, "node_modules/@zag-js/react": { - "version": "0.56.0", - "resolved": "https://registry.npmjs.org/@zag-js/react/-/react-0.56.0.tgz", - "integrity": "sha512-vD1EMVxmcwv1Ly0HD13LuylQd5tI26hKuh25I4IneCrIZiVNOJ0scFSsUvZqTAdRU4vOmqgGN9EjDq6RROx9YA==", + "version": "0.56.1", + "resolved": "https://registry.npmjs.org/@zag-js/react/-/react-0.56.1.tgz", + "integrity": "sha512-nF/1i5SCa8EJsumlP57cxIEV6C0lurc2ytunVfIL9o3IS005OnD/oqxCk5yxvaBdy6jvuGwa8VanOyDFdRJwOQ==", "dependencies": { - "@zag-js/core": "0.56.0", - "@zag-js/store": "0.56.0", - "@zag-js/types": "0.56.0", + "@zag-js/core": "0.56.1", + "@zag-js/store": "0.56.1", + "@zag-js/types": "0.56.1", "proxy-compare": "3.0.0" }, "peerDependencies": { @@ -13646,6 +13726,31 @@ "react-dom": ">=18.0.0" } }, + "node_modules/@zag-js/react/node_modules/@zag-js/core": { + "version": "0.56.1", + "resolved": "https://registry.npmjs.org/@zag-js/core/-/core-0.56.1.tgz", + "integrity": "sha512-3Iwum6+UmDxBqJIT4t2wSnr5hz83c6oRDS8j7I0hgV4U2Xt/IJMPedZt/KmV8ghjai/BlETskHUW1JvwZ4jaYA==", + "dependencies": { + "@zag-js/store": "0.56.1", + "klona": "2.0.6" + } + }, + "node_modules/@zag-js/react/node_modules/@zag-js/store": { + "version": "0.56.1", + "resolved": "https://registry.npmjs.org/@zag-js/store/-/store-0.56.1.tgz", + "integrity": "sha512-hc7lwqhor+WZXXXuCEV889QEHX7hBpTN0NUS+pHa8fED7QeJN+EhJZMwC3g6YLVde4w3MlxB6I92O5lruNNyEg==", + "dependencies": { + "proxy-compare": "3.0.0" + } + }, + "node_modules/@zag-js/react/node_modules/@zag-js/types": { + "version": "0.56.1", + "resolved": "https://registry.npmjs.org/@zag-js/types/-/types-0.56.1.tgz", + "integrity": "sha512-RkAO2PInKWo6oQmrFkwXpWO1VitL0jPs8D30r66/6+lSAEGLnCUifnkZchluJ8vlHvr04Q5emzFkIG4fQHLLmw==", + "dependencies": { + "csstype": "3.1.3" + } + }, "node_modules/@zag-js/store": { "version": "0.56.0", "resolved": "https://registry.npmjs.org/@zag-js/store/-/store-0.56.0.tgz", @@ -13654,6 +13759,19 @@ "proxy-compare": "3.0.0" } }, + "node_modules/@zag-js/text-selection": { + "version": "0.56.1", + "resolved": "https://registry.npmjs.org/@zag-js/text-selection/-/text-selection-0.56.1.tgz", + "integrity": "sha512-1r6Bz+/nLiyMw6N8opeQii6h/N7J6rNDuvys59QdbTc1rbI02DPNoRwGVfC/rBhSBK5h3vewQZePQokgnf39fQ==", + "dependencies": { + "@zag-js/dom-query": "0.56.1" + } + }, + "node_modules/@zag-js/text-selection/node_modules/@zag-js/dom-query": { + "version": "0.56.1", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-0.56.1.tgz", + "integrity": "sha512-mxUa7vzI+NhaMpf0D3cci0kmRuCPBkZCvoV+jsokkLfFKEUvAMVftnzxMxeydAEK6LFZL6PK6icOXP5FxbzzLA==" + }, "node_modules/@zag-js/types": { "version": "0.56.0", "resolved": "https://registry.npmjs.org/@zag-js/types/-/types-0.56.0.tgz", @@ -34673,6 +34791,24 @@ "url": "https://github.com/sponsors/wooorm" } }, + "packages/components/accordion": { + "name": "@spark-ui/accordion", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@spark-ui/icon": "^2.1.5", + "@spark-ui/icons": "^1.21.10", + "@spark-ui/slot": "^1.7.1", + "@zag-js/accordion": "^0.56.1", + "@zag-js/react": "^0.56.1" + }, + "peerDependencies": { + "@spark-ui/theme-utils": "^4.0.0", + "react": "^18.0 || ^19.0", + "react-dom": "^18.0 || ^19.0", + "tailwindcss": "^3.0.0" + } + }, "packages/components/alert-dialog": { "name": "@spark-ui/alert-dialog", "version": "1.0.20", diff --git a/packages/components/accordion/.npmignore b/packages/components/accordion/.npmignore new file mode 100644 index 0000000000..14144f5f74 --- /dev/null +++ b/packages/components/accordion/.npmignore @@ -0,0 +1,2 @@ +src +**/*.stories.* diff --git a/packages/components/accordion/LICENSE.md b/packages/components/accordion/LICENSE.md new file mode 100644 index 0000000000..62d3747810 --- /dev/null +++ b/packages/components/accordion/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Adevinta ASA. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/components/accordion/README.md b/packages/components/accordion/README.md new file mode 100644 index 0000000000..c48f1b9806 --- /dev/null +++ b/packages/components/accordion/README.md @@ -0,0 +1,13 @@ +# Accordion +> @spark-ui/accordion + +[![storybook](https://img.shields.io/badge/storybook-black?logo=storybook)](https://sparkui.vercel.app/?path=/docs/components-accordion--docs) +[![documentation](https://img.shields.io/badge/documentation-black?logo=googledocs)](https://sparkui-adv.vercel.app/docs/components/accordion) +[![issue](https://img.shields.io/badge/report%20a%20bug-black?logo=openbugbounty&logoColor=red)](https://github.com/adevinta/spark/issues/new?&projects=4&template=bug-report.yml&assignees=&labels=component,accordion) +[![npm](https://img.shields.io/npm/dt/%40spark-ui/accordion?logo=npm&labelColor=black)](https://www.npmjs.com/package/@spark-ui/accordion) + + +This package is part of the [`@spark-ui`](https://github.com/adevinta/spark) react-js user interface component library project. + +[![Issues open](https://img.shields.io/github/issues-search/adevinta/spark?query=is%3Aopen%20label%3Acomponent%20label%3Aaccordion&logo=openbugbounty&logoColor=red&label=issues%20open&color=red)](https://github.com/adevinta/spark/issues?q=is%3Aopen+label%3Acomponent+label%3Aaccordion) +[![NPM](https://img.shields.io/npm/l/%40spark-ui%2Faccordion)](https://github.com/adevinta/spark/blob/main/packages/components/accordion/LICENSE.md) diff --git a/packages/components/accordion/package.json b/packages/components/accordion/package.json new file mode 100644 index 0000000000..cedc854f08 --- /dev/null +++ b/packages/components/accordion/package.json @@ -0,0 +1,53 @@ +{ + "name": "@spark-ui/accordion", + "version": "0.0.0", + "description": "An accordion is a vertically stacked set of interactive headings containing a title, content snippet, or thumbnail representing a section of content.", + "publishConfig": { + "access": "public" + }, + "keywords": [ + "@spark-ui", + "react", + "component", + "accessible", + "accessibility", + "wai-aria", + "aria", + "a11y", + "accordion", + "disclosure" + ], + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "scripts": { + "build": "vite build" + }, + "peerDependencies": { + "@spark-ui/theme-utils": "^4.0.0", + "react": "^18.0 || ^19.0", + "react-dom": "^18.0 || ^19.0", + "tailwindcss": "^3.0.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/adevinta/spark.git", + "directory": "packages/components/accordion" + }, + "config": { + "title": "accordion", + "category": "components" + }, + "bugs": { + "url": "https://github.com/adevinta/spark/issues?q=is%3Aopen+label%3A%22Component%3A+accordion%22" + }, + "homepage": "https://sparkui.vercel.app", + "license": "MIT", + "dependencies": { + "@spark-ui/slot": "^1.7.1", + "@zag-js/accordion": "^0.56.1", + "@zag-js/react": "^0.56.1", + "@spark-ui/icon": "^2.1.5", + "@spark-ui/icons": "^1.21.10" + } +} diff --git a/packages/components/accordion/src/Accordion.doc.mdx b/packages/components/accordion/src/Accordion.doc.mdx new file mode 100644 index 0000000000..304275e9f9 --- /dev/null +++ b/packages/components/accordion/src/Accordion.doc.mdx @@ -0,0 +1,97 @@ +import { Meta, Canvas } from '@storybook/addon-docs' +import { ArgTypes } from '@docs/helpers/ArgTypes' +import { Kbd } from '@spark-ui/kbd' + +import { Accordion } from '.' + +import * as stories from './Accordion.stories' + + + +# Accordion + +An accordion is a vertically stacked set of interactive headings containing a title, content snippet, or thumbnail representing a section of content. + +## Install + +```sh +npm install @spark-ui/accordion +``` + +## Import + +```tsx +import { Accordion } from '@spark-ui/accordion' +``` + +## Props + + + +## Usage + +### Default + +A `Accordion` is closed by default. Interacting with its trigger will open the associated content. + + + +### Controlled + +Use `value` to control which panels are opened. + + + +### Disabled + +Use `disabled` to disabled the full `Accordion`. + + + +### Disabled item + +Use `disabled` on `Accordion.Item` to disabled a single panel. + + + +### Multiple + +Use `multiple` to allow multiple panels to be opened at the same time. + +Use `defaultValue` to pass an array of panels values to open by default. + + + +## Accessibility + +Adheres to the [Disclosure (Show/Hide) Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/). + +### Keyboard Interactions + +- Enter - activates the disclosure control and toggles the visibility of the disclosure + content. +- Space - activates the disclosure control and toggles the visibility of the disclosure + content. + +### WAI-ARIA Roles, States, and Properties + +- The element that shows and hides the content has role [button](https://w3c.github.io/aria/#button). +- When the content is visible, the element with role button has [aria-expanded](https://w3c.github.io/aria/#aria-expanded) set to `true`. When the content area is hidden, it is set to `false`. +- Optionally, the element with role button has a value specified for [aria-controls](https://w3c.github.io/aria/#aria-controls) that refers to the element that contains all the content that is shown or hidden. diff --git a/packages/components/accordion/src/Accordion.stories.tsx b/packages/components/accordion/src/Accordion.stories.tsx new file mode 100644 index 0000000000..f7714fe7e4 --- /dev/null +++ b/packages/components/accordion/src/Accordion.stories.tsx @@ -0,0 +1,207 @@ +import { Checkbox, CheckboxGroup } from '@spark-ui/checkbox' +import { Meta, StoryFn } from '@storybook/react' +import { useState } from 'react' + +import { Accordion } from '.' + +const meta: Meta = { + title: 'Experimental/Accordion', + component: Accordion, +} + +export default meta + +export const Default: StoryFn = () => { + return ( + + + Watercraft + +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor + incididunt ut labore et dolore magna aliqua. +

+
+
+ + + Automobiles + +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor + incididunt ut labore et dolore magna aliqua. +

+
+
+ + + Aircrafts + +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor + incididunt ut labore et dolore magna aliqua. +

+
+
+
+ ) +} + +export const Disabled: StoryFn = () => { + return ( + + + Watercraft + +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor + incididunt ut labore et dolore magna aliqua. +

+
+
+ + + Automobiles + +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor + incididunt ut labore et dolore magna aliqua. +

+
+
+ + + Aircrafts + +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor + incididunt ut labore et dolore magna aliqua. +

+
+
+
+ ) +} + +export const DisabledItem: StoryFn = () => { + return ( + + + Watercraft + +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor + incididunt ut labore et dolore magna aliqua. +

+
+
+ + + Automobiles + +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor + incididunt ut labore et dolore magna aliqua. +

+
+
+ + + Aircrafts + +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor + incididunt ut labore et dolore magna aliqua. +

+
+
+
+ ) +} + +export const Multiple: StoryFn = () => { + return ( + + + Watercraft + +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor + incididunt ut labore et dolore magna aliqua. +

+
+
+ + + Automobiles + +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor + incididunt ut labore et dolore magna aliqua. +

+
+
+ + + Aircrafts + +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor + incididunt ut labore et dolore magna aliqua. +

+
+
+
+ ) +} + +export const Controlled: StoryFn = () => { + const [value, setValue] = useState(['b', 'c']) + + return ( +
+ + Watercraft + Automobiles + Aircrafts + + + + + Watercraft + +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor + incididunt ut labore et dolore magna aliqua. +

+
+
+ + + Automobiles + +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor + incididunt ut labore et dolore magna aliqua. +

+
+
+ + + Aircrafts + +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor + incididunt ut labore et dolore magna aliqua. +

+
+
+
+
+ ) +} diff --git a/packages/components/accordion/src/Accordion.test.tsx b/packages/components/accordion/src/Accordion.test.tsx new file mode 100644 index 0000000000..3bdeb7de4f --- /dev/null +++ b/packages/components/accordion/src/Accordion.test.tsx @@ -0,0 +1,46 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it } from 'vitest' + +import { Accordion } from '.' + +describe('Accordion', () => { + it('should open the accordion content when clicking on the trigger', async () => { + const user = userEvent.setup() + + // Given an accordion in closed state + render( + + + First trigger + +

First panel

+
+
+ + + Second trigger + +

Second panel

+
+
+
+ ) + + // Then both panels are closed + expect(screen.getByText('First panel')).not.toBeVisible() + expect(screen.getByText('Second panel')).not.toBeVisible() + + await user.click(screen.getByRole('button', { name: 'First trigger' })) + + // Then first panel has been opened + expect(screen.getByText('First panel')).toBeVisible() + expect(screen.getByText('Second panel')).not.toBeVisible() + + await user.click(screen.getByRole('button', { name: 'Second trigger' })) + + // Then first panel has been closed and second panel has been opened + expect(screen.getByText('First panel')).not.toBeVisible() + expect(screen.getByText('Second panel')).toBeVisible() + }) +}) diff --git a/packages/components/accordion/src/Accordion.tsx b/packages/components/accordion/src/Accordion.tsx new file mode 100644 index 0000000000..d467e1ea6e --- /dev/null +++ b/packages/components/accordion/src/Accordion.tsx @@ -0,0 +1,112 @@ +import { Slot } from '@spark-ui/slot' +import * as accordion from '@zag-js/accordion' +import { mergeProps, normalizeProps, type PropTypes, useMachine } from '@zag-js/react' +import { cx } from 'class-variance-authority' +import { type ComponentPropsWithoutRef, createContext, forwardRef, useContext, useId } from 'react' + +type ExtentedZagInterface = Omit< + accordion.Context, + 'id' | 'ids' | 'orientation' | 'getRootNode' | 'onValueChange' +> & + ComponentPropsWithoutRef<'div'> + +export interface AccordionProps extends ExtentedZagInterface { + /** + * Change the default rendered element for the one passed as a child, merging their props and behavior. + */ + asChild?: boolean + /** + * Whether an accordion item can be closed after it has been expanded. + */ + collapsible?: boolean + defaultValue?: accordion.Context['value'] + /** + * Whether the accordion items are disabled + */ + disabled?: boolean + /** + * Whether multple accordion items can be expanded at the same time. + */ + multiple?: boolean + /** + * The `value` of the accordion items that are currently being expanded. + */ + value?: string[] + /** + * The callback fired when the state of expanded/collapsed accordion items changes. + */ + onValueChange?: (value: string[]) => void +} + +const AccordionContext = createContext | null>(null) + +export const Accordion = forwardRef( + ( + { + asChild = false, + children, + collapsible = true, + className, + defaultValue, + disabled = false, + multiple = false, + value, + onValueChange, + ...props + }, + ref + ) => { + const [machineProps, localProps] = accordion.splitProps({ + children, + multiple, + collapsible, + value, + disabled, + // onValueChange, + className: cx('bg-surface rounded-lg', className), + ...props, + }) + + const [state, send] = useMachine( + // Initial state + accordion.machine({ + ...machineProps, + value: defaultValue, + id: useId(), + onValueChange(details) { + onValueChange?.(details.value) + }, + }), + // Dynamic state + { context: machineProps } + ) + + const api = accordion.connect(state, send, normalizeProps) + + const Component = asChild ? Slot : 'div' + + return ( + + + {children} + + + ) + } +) + +Accordion.displayName = 'Accordion' + +export const useAccordionContext = () => { + const context = useContext(AccordionContext) + + if (!context) { + throw Error('useAccordionContext must be used within a Accordion provider') + } + + return context +} diff --git a/packages/components/accordion/src/AccordionItem.tsx b/packages/components/accordion/src/AccordionItem.tsx new file mode 100644 index 0000000000..e3ffdc2e4e --- /dev/null +++ b/packages/components/accordion/src/AccordionItem.tsx @@ -0,0 +1,56 @@ +import { Slot } from '@spark-ui/slot' +import { mergeProps } from '@zag-js/react' +import { cx } from 'class-variance-authority' +import { type ComponentPropsWithoutRef, forwardRef, type Ref } from 'react' + +import { useAccordionContext } from './Accordion' +import { AccordionItemProvider } from './AccordionItemContext' + +export interface AccordionItemProps extends ComponentPropsWithoutRef<'div'> { + value: string + asChild?: boolean + disabled?: boolean +} + +export const Item = forwardRef( + ({ children, ...props }: AccordionItemProps, forwardedRef: Ref) => { + return ( + + + {children} + + + ) + } +) + +const InnerItem = forwardRef( + ({ asChild = false, className, children, disabled = false, value, ...props }, ref) => { + const { getItemProps } = useAccordionContext() + + const Component = asChild ? Slot : 'div' + + const styles = cx( + 'relative border-sm border-outline', + 'first:rounded-t-lg last:rounded-b-lg', + '[&:not(:last-child)]:border-b-none', + + className + ) + + return ( + + {children} + + ) + } +) + +Item.displayName = 'Accordion.Item' diff --git a/packages/components/accordion/src/AccordionItemContent.tsx b/packages/components/accordion/src/AccordionItemContent.tsx new file mode 100644 index 0000000000..c3747d2f7d --- /dev/null +++ b/packages/components/accordion/src/AccordionItemContent.tsx @@ -0,0 +1,39 @@ +import { Slot } from '@spark-ui/slot' +import { mergeProps } from '@zag-js/react' +import { cx } from 'class-variance-authority' +import { type ComponentPropsWithoutRef, forwardRef } from 'react' + +import { useAccordionContext } from './Accordion' +import { useAccordionItemContext } from './AccordionItemContext' + +export interface AccordionItemContentProps extends ComponentPropsWithoutRef<'div'> { + asChild?: boolean +} + +export const ItemContent = forwardRef( + ({ asChild = false, className, children, ...props }, ref) => { + const { getItemContentProps } = useAccordionContext() + const { value, disabled } = useAccordionItemContext() + + const Component = asChild ? Slot : 'div' + + return ( + + {children} + + ) + } +) + +ItemContent.displayName = 'Accordion.ItemContent' diff --git a/packages/components/accordion/src/AccordionItemContext.tsx b/packages/components/accordion/src/AccordionItemContext.tsx new file mode 100644 index 0000000000..8fb49237fb --- /dev/null +++ b/packages/components/accordion/src/AccordionItemContext.tsx @@ -0,0 +1,35 @@ +import React, { createContext, type PropsWithChildren, useContext, useState } from 'react' + +interface AccordionItemContextState { + value: string + setValue: React.Dispatch> + disabled: boolean + setDisabled: React.Dispatch> +} + +const AccordionItemContext = createContext(null) + +export const AccordionItemProvider = ({ + value: valueProp, + disabled: disabledProp = false, + children, +}: PropsWithChildren<{ value: string; disabled?: boolean }>) => { + const [value, setValue] = useState(valueProp) + const [disabled, setDisabled] = useState(disabledProp) + + return ( + + {children} + + ) +} + +export const useAccordionItemContext = () => { + const context = useContext(AccordionItemContext) + + if (!context) { + throw Error('useAccordionItemContext must be used within a AccordionItem provider') + } + + return context +} diff --git a/packages/components/accordion/src/AccordionItemTrigger.tsx b/packages/components/accordion/src/AccordionItemTrigger.tsx new file mode 100644 index 0000000000..21d4795eb2 --- /dev/null +++ b/packages/components/accordion/src/AccordionItemTrigger.tsx @@ -0,0 +1,55 @@ +import { Icon } from '@spark-ui/icon' +import { ArrowHorizontalDown } from '@spark-ui/icons/dist/icons/ArrowHorizontalDown' +import { Slot } from '@spark-ui/slot' +import { mergeProps } from '@zag-js/react' +import { cx } from 'class-variance-authority' +import { type ComponentPropsWithoutRef, forwardRef } from 'react' + +import { useAccordionContext } from './Accordion' +import { useAccordionItemContext } from './AccordionItemContext' + +export interface AccordionItemTriggerProps extends ComponentPropsWithoutRef<'button'> { + asChild?: boolean +} + +export const ItemTrigger = forwardRef( + ({ asChild = false, children, className, ...props }, ref) => { + const { getItemTriggerProps } = useAccordionContext() + const { value, disabled } = useAccordionItemContext() + + const Component = asChild ? Slot : 'button' + + const itemTriggerProps = mergeProps( + getItemTriggerProps({ value, ...(disabled && { disabled }) }), + { + ...props, + className: cx( + 'relative flex gap-lg justify-between items-center', + 'w-full px-lg py-md text-left text-headline-2 text-on-surface rounded-[inherit] data-[state=open]:rounded-b-none', + 'hover:enabled:bg-surface-hovered focus:bg-surface-hovered', + 'focus-visible:u-ring focus-visible:outline-none focus-visible:z-raised', + 'disabled:opacity-dim-3 disabled:cursor-not-allowed', + className + ), + } + ) + + const isOpen = !!itemTriggerProps['aria-expanded'] + + return ( + +
{children}
+ + + +
+ ) + } +) + +ItemTrigger.displayName = 'Accordion.ItemTrigger' diff --git a/packages/components/accordion/src/index.ts b/packages/components/accordion/src/index.ts new file mode 100644 index 0000000000..464573e374 --- /dev/null +++ b/packages/components/accordion/src/index.ts @@ -0,0 +1,25 @@ +import type { FC } from 'react' + +import { Accordion as Root, type AccordionProps } from './Accordion' +import { Item } from './AccordionItem' +import { ItemContent } from './AccordionItemContent' +import { ItemTrigger } from './AccordionItemTrigger' + +export const Accordion: FC & { + Item: typeof Item + ItemTrigger: typeof ItemTrigger + ItemContent: typeof ItemContent +} = Object.assign(Root, { + Item, + ItemTrigger, + ItemContent, +}) + +Accordion.displayName = 'Accordion' +Item.displayName = 'Item' +ItemTrigger.displayName = 'Accordion.Trigger' +ItemContent.displayName = 'Accordion.Content' + +export { type AccordionProps } from './Accordion' +export { type AccordionItemContentProps } from './AccordionItemContent' +export { type AccordionItemTriggerProps } from './AccordionItemTrigger' diff --git a/packages/components/accordion/tsconfig.json b/packages/components/accordion/tsconfig.json new file mode 100644 index 0000000000..18f4c0e16e --- /dev/null +++ b/packages/components/accordion/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.json", + "include": ["src/**/*", "../../../global.d.ts"] +} diff --git a/packages/components/accordion/vite.config.ts b/packages/components/accordion/vite.config.ts new file mode 100644 index 0000000000..64973e5ac4 --- /dev/null +++ b/packages/components/accordion/vite.config.ts @@ -0,0 +1,6 @@ +import path from 'path' +import { getComponentConfiguration } from '../../../config/index' + +const { name } = require(path.resolve(__dirname, 'package.json')) + +export default getComponentConfiguration(process.cwd(), name) diff --git a/packages/components/collapsible/package.json b/packages/components/collapsible/package.json index f64cd15515..23f7a014f2 100644 --- a/packages/components/collapsible/package.json +++ b/packages/components/collapsible/package.json @@ -39,7 +39,7 @@ "category": "components" }, "bugs": { - "url": "https://github.com/adevinta/spark/issues?q=is%3Aopen+label%3Autility+label%3Acollapsible" + "url": "https://github.com/adevinta/spark/issues?q=is%3Aopen+label%3A%22Component%3A+collapsible%22" }, "homepage": "https://sparkui.vercel.app", "license": "MIT", diff --git a/packages/components/collapsible/src/Collapsible.styles.ts b/packages/components/collapsible/src/Collapsible.styles.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/packages/utils/cli/src/generate/templates/component/[package.json].js b/packages/utils/cli/src/generate/templates/component/[package.json].js index 2d7018f88b..e3d5817cf2 100644 --- a/packages/utils/cli/src/generate/templates/component/[package.json].js +++ b/packages/utils/cli/src/generate/templates/component/[package.json].js @@ -38,7 +38,7 @@ export default ({ name, description }) => `{ "category": "components" }, "bugs": { - "url": "https://github.com/adevinta/spark/issues?q=is%3Aopen+label%3Autility+label%3A${name}" + "url": "https://github.com/adevinta/spark/issues?q=is%3Aopen+label%3A%22Component%3A+${name}%22" }, "homepage": "https://sparkui.vercel.app", "license": "MIT"