From 9421c1409a81537577de8b7fe10ae30cfe36d2cc Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Fri, 20 Sep 2024 12:54:43 -0500 Subject: [PATCH] Accordion (#6931) * initialize accordion item * update yarn.lock * better hidden=until-found support * allow passing in panel ref * comments * use RAC in v3 Accordion * use disclosure hooks * initialize S2 Accordion * fix lint * add exports * lint * fix chevron color in dark mode * fix version of @react-stately/accordion * keep aria-controls even when closed * add isFocusVisibleWithin to AccordionPanel * fix panel height * fix versions * add to ts strict config * change open/close to expand/collapse * move to disclosure package * fix chevron shrinking on wrapping header text * support for disabled in S2 * clear ButtonContext in panel * update colors * lint * fix v3 chevron not rotating * don't open via onbeforematch if disabled * add disableTapHighlight * open disclosure onKeyDown * add getAllowedOverrides * remove outer header component * support level in S2 header * support level in v3 header * switch divider to use border * simplify focus ring and padding styles * update AccordionItem children types to enforce two React elements * remove Header from RAC example * scale font size * update yarn.lock * fix keydown interaction + types * support size, density, isQuiet, and isDisabled on individual items, but use group prop if available * enforce minWidth at item level * add isFocusVisibleWithin to item * fix chevron in RTL * revert changes to @react-aria/accordion * fix v3 chevron in RTL * fix packages/imports * use 'group' as default role * deprecate useAccordion and useAccordionItem * update prop JSDocs * update yarn.lock * fix v3 refs * add v3 tests back * update imports * remove @ts-ignore * Revert "remove @ts-ignore" This reverts commit 88c1604c54c787c69a7d69b350ef556b38e96c6a. * fix stories * fix context for individual accordion item * fix defaults for individual item * add story for individual item * make paddingTop and paddingBottom equal * fix story * change triggerProps to buttonProps * add optional ref to useDisclosure * add SSR check * use useEvent to add beforematch listener * rename AccordionGroup to Accordion * rename Disclosure to AccordionItem * rename AccordionPanel to DisclosurePanel * more renaming * rename RAC Accordion file to Disclosure * comment updates * package.json updates * yarn.lock * update render props * add DEFAULT_SLOT * use fontRelative for border radius * style macro updates * lint * update codemod * fix tests * use control for borderRadius * use ref instead of contentRef prop * use values on grid * lint * yarn.lock * fix refs * lint * revert renames in useAccordion * move Disclosure to its own file * reanme RAC Accordion stories to Disclosure * fix styles prop type * fix imports * update styles type on Disclosure * add dedicated story for S2 Disclosure * Slight fixes * Center baseline and simplify padding by changing the minHeight instead It uses more normal numbers * fix import * update lockfile --------- Co-authored-by: Devon Govett --- .../components/accordion/index.css | 8 +- .../components/accordion/skin.css | 2 +- .../@react-aria/accordion/src/useAccordion.ts | 6 + packages/@react-aria/disclosure/README.md | 3 + packages/@react-aria/disclosure/index.ts | 13 + packages/@react-aria/disclosure/package.json | 43 +++ packages/@react-aria/disclosure/src/index.ts | 13 + .../disclosure/src/useDisclosure.ts | 93 ++++++ .../chromatic-fc/Accordion.stories.tsx | 7 +- .../accordion/chromatic/Accordion.stories.tsx | 50 +-- .../@react-spectrum/accordion/package.json | 4 +- .../accordion/src/Accordion.tsx | 134 ++++---- .../@react-spectrum/accordion/src/index.ts | 5 +- .../accordion/stories/Accordion.stories.tsx | 95 ++---- .../accordion/test/Accordion.test.js | 52 ++-- packages/@react-spectrum/s2/src/Accordion.tsx | 84 +++++ .../@react-spectrum/s2/src/Disclosure.tsx | 291 ++++++++++++++++++ packages/@react-spectrum/s2/src/index.ts | 4 + .../s2/stories/Accordion.stories.tsx | 145 +++++++++ .../s2/stories/Disclosure.stories.tsx | 84 +++++ packages/@react-stately/disclosure/README.md | 3 + packages/@react-stately/disclosure/index.ts | 13 + .../@react-stately/disclosure/package.json | 35 +++ .../@react-stately/disclosure/src/index.ts | 15 + .../disclosure/src/useDisclosureState.ts | 65 ++++ .../multi-collection.test.ts.snap | 7 +- .../__tests__/multi-collection.test.ts | 7 +- packages/react-aria-components/package.json | 3 + .../react-aria-components/src/Disclosure.tsx | 150 +++++++++ packages/react-aria-components/src/index.ts | 2 + .../stories/Disclosure.stories.tsx | 54 ++++ .../react-aria-components/stories/styles.css | 11 + tsconfig.json | 2 + yarn.lock | 37 ++- 34 files changed, 1328 insertions(+), 212 deletions(-) create mode 100644 packages/@react-aria/disclosure/README.md create mode 100644 packages/@react-aria/disclosure/index.ts create mode 100644 packages/@react-aria/disclosure/package.json create mode 100644 packages/@react-aria/disclosure/src/index.ts create mode 100644 packages/@react-aria/disclosure/src/useDisclosure.ts create mode 100644 packages/@react-spectrum/s2/src/Accordion.tsx create mode 100644 packages/@react-spectrum/s2/src/Disclosure.tsx create mode 100644 packages/@react-spectrum/s2/stories/Accordion.stories.tsx create mode 100644 packages/@react-spectrum/s2/stories/Disclosure.stories.tsx create mode 100644 packages/@react-stately/disclosure/README.md create mode 100644 packages/@react-stately/disclosure/index.ts create mode 100644 packages/@react-stately/disclosure/package.json create mode 100644 packages/@react-stately/disclosure/src/index.ts create mode 100644 packages/@react-stately/disclosure/src/useDisclosureState.ts create mode 100644 packages/react-aria-components/src/Disclosure.tsx create mode 100644 packages/react-aria-components/stories/Disclosure.stories.tsx diff --git a/packages/@adobe/spectrum-css-temp/components/accordion/index.css b/packages/@adobe/spectrum-css-temp/components/accordion/index.css index 74ddd3b695d..06bf460ebc2 100644 --- a/packages/@adobe/spectrum-css-temp/components/accordion/index.css +++ b/packages/@adobe/spectrum-css-temp/components/accordion/index.css @@ -32,7 +32,7 @@ governing permissions and limitations under the License. .spectrum-Accordion-itemIndicator { display: block; - padding-inline-start: var(--spectrum-accordion-icon-spacing); + padding-inline-start: var(--spectrum-accordion-icon-gap); padding-inline-end: var(--spectrum-accordion-icon-gap); transition: transform ease var(--spectrum-global-animation-duration-100); @@ -66,7 +66,7 @@ governing permissions and limitations under the License. box-sizing: border-box; /* left padding takes into account the icon's size as well as the focus state's left border */ padding-block: var(--spectrum-accordion-item-title-padding-y); - padding-inline-start: 2px; + padding-inline-start: var(--spectrum-accordion-icon-gap); padding-inline-end: var(--spectrum-accordion-item-padding); margin: 0; @@ -111,7 +111,7 @@ governing permissions and limitations under the License. } .spectrum-Accordion-item { - &.is-open { + &.is-expanded { > .spectrum-Accordion-itemHeading { > .spectrum-Accordion-itemHeader { > .spectrum-Accordion-itemIndicator { @@ -126,7 +126,7 @@ governing permissions and limitations under the License. } > .spectrum-Accordion-itemHeader::after { - /* No bottom border when open, so be less tall */ + /* No bottom border when expanded, so be less tall */ height: var(--spectrum-accordion-item-height-actual); } diff --git a/packages/@adobe/spectrum-css-temp/components/accordion/skin.css b/packages/@adobe/spectrum-css-temp/components/accordion/skin.css index c58b1b08fa4..3b50c940329 100644 --- a/packages/@adobe/spectrum-css-temp/components/accordion/skin.css +++ b/packages/@adobe/spectrum-css-temp/components/accordion/skin.css @@ -39,7 +39,7 @@ governing permissions and limitations under the License. } .spectrum-Accordion-item { - &.is-open { + &.is-expanded { .spectrum-Accordion-itemHeader { &:hover { background-color: transparent; diff --git a/packages/@react-aria/accordion/src/useAccordion.ts b/packages/@react-aria/accordion/src/useAccordion.ts index d36d9c8ef5b..e5ee4d3a11b 100644 --- a/packages/@react-aria/accordion/src/useAccordion.ts +++ b/packages/@react-aria/accordion/src/useAccordion.ts @@ -34,6 +34,9 @@ export interface AccordionItemAria { regionProps: DOMAttributes } +/** + * @deprecated Use useDisclosure from `@react-aria/disclosure` instead. + */ export function useAccordionItem(props: AccordionItemAriaProps, state: TreeState, ref: RefObject): AccordionItemAria { let {item} = props; let buttonId = useId(); @@ -65,6 +68,9 @@ export function useAccordionItem(props: AccordionItemAriaProps, state: Tre }; } +/** + * @deprecated Use useDisclosure from `@react-aria/disclosure` instead. + */ export function useAccordion(props: AriaAccordionProps, state: TreeState, ref: RefObject): AccordionAria { let {listProps} = useSelectableList({ ...props, diff --git a/packages/@react-aria/disclosure/README.md b/packages/@react-aria/disclosure/README.md new file mode 100644 index 00000000000..776a49f9638 --- /dev/null +++ b/packages/@react-aria/disclosure/README.md @@ -0,0 +1,3 @@ +# @react-aria/disclosure + +This package is part of [react-spectrum](https://github.com/adobe/react-spectrum). See the repo for more details. diff --git a/packages/@react-aria/disclosure/index.ts b/packages/@react-aria/disclosure/index.ts new file mode 100644 index 00000000000..1210ae1e402 --- /dev/null +++ b/packages/@react-aria/disclosure/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +export * from './src'; diff --git a/packages/@react-aria/disclosure/package.json b/packages/@react-aria/disclosure/package.json new file mode 100644 index 00000000000..7ca88734b13 --- /dev/null +++ b/packages/@react-aria/disclosure/package.json @@ -0,0 +1,43 @@ +{ + "name": "@react-aria/disclosure", + "version": "3.0.0-alpha.0", + "description": "Spectrum UI components in React", + "license": "Apache-2.0", + "main": "dist/main.js", + "module": "dist/module.js", + "exports": { + "types": "./dist/types.d.ts", + "import": "./dist/import.mjs", + "require": "./dist/main.js" + }, + "types": "dist/types.d.ts", + "source": "src/index.ts", + "files": [ + "dist", + "src" + ], + "sideEffects": false, + "repository": { + "type": "git", + "url": "https://github.com/adobe/react-spectrum" + }, + "dependencies": { + "@react-aria/button": "^3.9.8", + "@react-aria/selection": "^3.19.3", + "@react-aria/ssr": "^3.9.5", + "@react-aria/utils": "^3.25.2", + "@react-stately/disclosure": "3.0.0-alpha.0", + "@react-stately/toggle": "^3.7.7", + "@react-stately/tree": "^3.8.4", + "@react-types/button": "^3.9.6", + "@react-types/shared": "^3.24.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/@react-aria/disclosure/src/index.ts b/packages/@react-aria/disclosure/src/index.ts new file mode 100644 index 00000000000..2357cfcbcfb --- /dev/null +++ b/packages/@react-aria/disclosure/src/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +export {useDisclosure} from './useDisclosure'; +export type {DisclosureAria, AriaDisclosureProps} from './useDisclosure'; diff --git a/packages/@react-aria/disclosure/src/useDisclosure.ts b/packages/@react-aria/disclosure/src/useDisclosure.ts new file mode 100644 index 00000000000..8260d8ccb98 --- /dev/null +++ b/packages/@react-aria/disclosure/src/useDisclosure.ts @@ -0,0 +1,93 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the 'License'); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {AriaButtonProps} from '@react-types/button'; +import {DisclosureState} from '@react-stately/disclosure'; +import {HTMLAttributes, RefObject, useEffect} from 'react'; +import {useEvent, useId} from '@react-aria/utils'; +import {useIsSSR} from '@react-aria/ssr'; + +export interface AriaDisclosureProps { + /** Whether the disclosure is disabled. */ + isDisabled?: boolean, + /** Handler that is called when the disclosure's expanded state changes. */ + onExpandedChange?: (isExpanded: boolean) => void, + /** Whether the disclosure is expanded (controlled). */ + isExpanded?: boolean, + /** Whether the disclosure is expanded by default (uncontrolled). */ + defaultExpanded?: boolean +} + +export interface DisclosureAria { + /** Props for the disclosure button. */ + buttonProps: AriaButtonProps, + /** Props for the content element. */ + contentProps: HTMLAttributes +} + +/** + * Provides the behavior and accessibility implementation for a disclosure component. + * @param props - Props for the disclosure. + * @param state - State for the disclosure, as returned by `useDisclosureState`. + * @param ref - A ref for the disclosure content. + */ +export function useDisclosure(props: AriaDisclosureProps, state: DisclosureState, ref?: RefObject): DisclosureAria { + let { + isDisabled + } = props; + let triggerId = useId(); + let contentId = useId(); + let isControlled = props.isExpanded !== undefined; + let isSSR = useIsSSR(); + let supportsBeforeMatch = !isSSR && 'onbeforematch' in document.body; + + // @ts-ignore https://github.com/facebook/react/pull/24741 + useEvent(ref, 'beforematch', supportsBeforeMatch ? () => state.expand() : null); + + useEffect(() => { + // Until React supports hidden="until-found": https://github.com/facebook/react/pull/24741 + if (supportsBeforeMatch && ref?.current && !isControlled && !isDisabled) { + if (state.isExpanded) { + // @ts-ignore + ref.current.hidden = undefined; + } else { + // @ts-ignore + ref.current.hidden = 'until-found'; + } + } + }, [isControlled, ref, props.isExpanded, state, supportsBeforeMatch, isDisabled]); + + return { + buttonProps: { + id: triggerId, + 'aria-expanded': state.isExpanded, + 'aria-controls': contentId, + onPress: (e) => { + if (e.pointerType !== 'keyboard') { + state.toggle(); + } + }, + isDisabled, + onKeyDown(e) { + if (!isDisabled && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault(); + state.toggle(); + } + } + }, + contentProps: { + id: contentId, + 'aria-labelledby': triggerId, + hidden: (!supportsBeforeMatch || isControlled) ? !state.isExpanded : true + } + }; +} diff --git a/packages/@react-spectrum/accordion/chromatic-fc/Accordion.stories.tsx b/packages/@react-spectrum/accordion/chromatic-fc/Accordion.stories.tsx index 485dd7d7e4e..17c37ecb911 100644 --- a/packages/@react-spectrum/accordion/chromatic-fc/Accordion.stories.tsx +++ b/packages/@react-spectrum/accordion/chromatic-fc/Accordion.stories.tsx @@ -11,16 +11,15 @@ */ import {Meta} from '@storybook/react'; -import {SpectrumAccordionProps} from '@react-types/accordion'; +import {SpectrumAccordionProps} from '../src/Accordion'; import {Template} from '../chromatic/Accordion.stories'; -const meta: Meta> = { +const meta: Meta = { title: 'Accordion' }; export default meta; export const Default = { - render: Template, - args: {defaultExpandedKeys: ['shared'], disabledKeys: ['last']} + render: Template }; diff --git a/packages/@react-spectrum/accordion/chromatic/Accordion.stories.tsx b/packages/@react-spectrum/accordion/chromatic/Accordion.stories.tsx index 03c8ba5dd4e..f259640f6e3 100644 --- a/packages/@react-spectrum/accordion/chromatic/Accordion.stories.tsx +++ b/packages/@react-spectrum/accordion/chromatic/Accordion.stories.tsx @@ -10,14 +10,13 @@ * governing permissions and limitations under the License. */ -import {Accordion, Item} from '../'; +import {Accordion, Disclosure, DisclosureHeader, DisclosurePanel} from '../'; import {Meta} from '@storybook/react'; import React from 'react'; -import {SpectrumAccordionProps} from '@react-types/accordion'; -const meta: Meta> = { +const meta: Meta = { title: 'Accordion', - component: Accordion, + component: Disclosure, excludeStories: ['Template'] }; @@ -25,15 +24,30 @@ export default meta; export const Template = (args) => ( - - files - - - shared - - - last - + + + Your files + + + files + + + + + Shared with you + + + shared + + + + + Last item + + + last + + ); @@ -41,12 +55,4 @@ export const Default = { render: Template }; -export const ExpandedKeys = { - render: Template, - args: {defaultExpandedKeys: ['shared']} -}; - -export const DisabledKeys = { - render: Template, - args: {disabledKeys: ['shared']} -}; +// TODO: more stories diff --git a/packages/@react-spectrum/accordion/package.json b/packages/@react-spectrum/accordion/package.json index 8436968338f..d8b74e70fc3 100644 --- a/packages/@react-spectrum/accordion/package.json +++ b/packages/@react-spectrum/accordion/package.json @@ -44,10 +44,10 @@ "@react-spectrum/utils": "^3.11.9", "@react-stately/collections": "^3.10.9", "@react-stately/tree": "^3.8.3", - "@react-types/accordion": "3.0.0-alpha.23", "@react-types/shared": "^3.24.1", "@spectrum-icons/ui": "^3.6.9", - "@swc/helpers": "^0.5.0" + "@swc/helpers": "^0.5.0", + "react-aria-components": "^1.3.3" }, "devDependencies": { "@adobe/spectrum-css-temp": "3.0.0-alpha.1" diff --git a/packages/@react-spectrum/accordion/src/Accordion.tsx b/packages/@react-spectrum/accordion/src/Accordion.tsx index b61f049f5a5..f068a191d17 100644 --- a/packages/@react-spectrum/accordion/src/Accordion.tsx +++ b/packages/@react-spectrum/accordion/src/Accordion.tsx @@ -10,91 +10,121 @@ * governing permissions and limitations under the License. */ +import {AriaLabelingProps, DOMProps, DOMRef, StyleProps} from '@react-types/shared'; +import {Button, DisclosurePanelProps, DisclosureProps, Heading, Disclosure as RACDisclosure, DisclosurePanel as RACDisclosurePanel} from 'react-aria-components'; import ChevronLeftMedium from '@spectrum-icons/ui/ChevronLeftMedium'; import ChevronRightMedium from '@spectrum-icons/ui/ChevronRightMedium'; import {classNames, useDOMRef, useStyleProps} from '@react-spectrum/utils'; -import {DOMRef, Node} from '@react-types/shared'; -import {filterDOMProps, mergeProps} from '@react-aria/utils'; -import {FocusRing} from '@react-aria/focus'; -import React, {forwardRef, useRef} from 'react'; -import {SpectrumAccordionProps} from '@react-types/accordion'; +import {filterDOMProps} from '@react-aria/utils'; +import React, {forwardRef, ReactElement} from 'react'; import styles from '@adobe/spectrum-css-temp/components/accordion/vars.css'; -import {TreeState, useTreeState} from '@react-stately/tree'; -import {useAccordion, useAccordionItem} from '@react-aria/accordion'; -import {useHover} from '@react-aria/interactions'; import {useLocale} from '@react-aria/i18n'; import {useProviderProps} from '@react-spectrum/provider'; +export interface SpectrumAccordionProps extends StyleProps, DOMProps, AriaLabelingProps { + /** The disclosures within the accordion group. */ + children: React.ReactNode +} -function Accordion(props: SpectrumAccordionProps, ref: DOMRef) { +function Accordion(props: SpectrumAccordionProps, ref: DOMRef) { props = useProviderProps(props); - let state = useTreeState(props); let {styleProps} = useStyleProps(props); let domRef = useDOMRef(ref); - let {accordionProps} = useAccordion(props, state, domRef); - return (
- {[...state.collection].map(item => ( - key={item.key} item={item} state={state} /> - ))} + {props.children}
); } -interface AccordionItemProps { - item: Node, - state: TreeState +export interface SpectrumDisclosureProps extends DisclosureProps, DOMProps, AriaLabelingProps { + /** The contents of the disclosure. The first child should be the header, and the second child should be the panel. */ + children: [ReactElement, ReactElement] } -function AccordionItem(props: AccordionItemProps) { +function Disclosure(props: SpectrumDisclosureProps, ref: DOMRef) { props = useProviderProps(props); - let ref = useRef(null); - let {state, item} = props; - let {buttonProps, regionProps} = useAccordionItem(props, state, ref); - let isOpen = state.expandedKeys.has(item.key); - let isDisabled = state.disabledKeys.has(item.key); - let {isHovered, hoverProps} = useHover({isDisabled}); - let {direction} = useLocale(); - + let domRef = useDOMRef(ref); return ( -
classNames(styles, 'spectrum-Accordion-item', { + 'is-expanded': isExpanded, 'is-disabled': isDisabled })}> -

- - - -

-
- {item.props.children} -
-
+ {props.children} + + ); } -const _Accordion = forwardRef(Accordion) as (props: SpectrumAccordionProps & {ref?: DOMRef}) => ReturnType; +/** A group of disclosures that can be expanded and collapsed. */ +const _Accordion = forwardRef(Accordion) as (props: SpectrumAccordionProps & {ref?: DOMRef}) => ReturnType; export {_Accordion as Accordion}; + +/** A collapsible section of content composed of a heading that expands and collapses a panel. */ +const _Disclosure = forwardRef(Disclosure) as (props: SpectrumDisclosureProps & {ref?: DOMRef}) => ReturnType; +export {_Disclosure as Disclosure}; + +/** The panel that contains the content of an disclosure. */ +const _DisclosurePanel = forwardRef(DisclosurePanel) as (props: SpectrumDisclosurePanelProps & {ref?: DOMRef}) => ReturnType; +export {_DisclosurePanel as DisclosurePanel}; + +/** The heading of the disclosure. */ +const _DisclosureHeader = forwardRef(DisclosureHeader) as (props: SpectrumDisclosureHeaderProps & {ref?: DOMRef}) => ReturnType; +export {_DisclosureHeader as DisclosureHeader}; diff --git a/packages/@react-spectrum/accordion/src/index.ts b/packages/@react-spectrum/accordion/src/index.ts index 7816e55ba78..84d1b11a5aa 100644 --- a/packages/@react-spectrum/accordion/src/index.ts +++ b/packages/@react-spectrum/accordion/src/index.ts @@ -10,6 +10,5 @@ * governing permissions and limitations under the License. */ /// -export {Accordion} from './Accordion'; -export {Item} from '@react-stately/collections'; -export type {SpectrumAccordionProps} from '@react-types/accordion'; +export {Disclosure, Accordion, DisclosureHeader, DisclosurePanel} from './Accordion'; +export type {SpectrumAccordionProps, SpectrumDisclosureProps, SpectrumDisclosurePanelProps, SpectrumDisclosureHeaderProps} from './Accordion'; diff --git a/packages/@react-spectrum/accordion/stories/Accordion.stories.tsx b/packages/@react-spectrum/accordion/stories/Accordion.stories.tsx index 1c0b89ba908..417470ddcc0 100644 --- a/packages/@react-spectrum/accordion/stories/Accordion.stories.tsx +++ b/packages/@react-spectrum/accordion/stories/Accordion.stories.tsx @@ -10,16 +10,9 @@ * governing permissions and limitations under the License. */ -import {Accordion, Item} from '../src'; +import {Accordion, Disclosure, DisclosureHeader, DisclosurePanel} from '../src'; import {ComponentMeta, ComponentStoryObj} from '@storybook/react'; -import {Key} from '@react-types/shared'; -import React, {useState} from 'react'; -import {SpectrumAccordionProps} from '@react-types/accordion'; - -type ItemType = { - key: Key, - title: string -}; +import React from 'react'; export default { title: 'Accordion', @@ -30,76 +23,24 @@ export default { export type AccordionStory = ComponentStoryObj; export const Default: AccordionStory = { - args: { - items: [ - {key: 'files', title: 'Your files'}, - {key: 'shared', title: 'Shared with you'}, - {key: 'last', title: 'Last item'} - ] - }, render: (args) => ( - {(item) => {(item as ItemType).key}} + + + Files + + +

Files content

+
+
+ + + People + + +

People content

+
+
) }; - -export const DefaultExpandedKeys: AccordionStory = { - args: {...Default.args, defaultExpandedKeys: ['files']}, - render: Default.render, - name: 'defaultExpandedKeys: files' -}; - -export const DisabledKeys: AccordionStory = { - args: {...Default.args, disabledKeys: ['files', 'shared']}, - render: Default.render, - name: 'disabledKeys: files, shared' -}; - -export const DisabledDefaultExpandedKeys: AccordionStory = { - args: {...Default.args, defaultExpandedKeys: ['files'], disabledKeys: ['files', 'shared']}, - render: Default.render, - name: 'defaultExpandedKeys: files, disabledKeys: files, shared' -}; - -export const ControlledExpandedKeys: AccordionStory = { - args: {...Default.args, defaultExpandedKeys: ['files']}, - render: (args) => , - name: 'controlled ExpandedKeys' -}; - -export const WithInput: AccordionStory = { - args: {...Default.args, defaultExpandedKeys: ['step1']}, - render: (args) => ( - - - - - - - - - - - - ), - name: 'With input' -}; - - -function ControlledAccordion(props: SpectrumAccordionProps) { - let [openKeys, setOpenKeys] = useState>(new Set(['files'])); - return ( - - - files - - - shared - - - last - - - ); -} diff --git a/packages/@react-spectrum/accordion/test/Accordion.test.js b/packages/@react-spectrum/accordion/test/Accordion.test.js index 27a9135542b..42500562619 100644 --- a/packages/@react-spectrum/accordion/test/Accordion.test.js +++ b/packages/@react-spectrum/accordion/test/Accordion.test.js @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {Accordion, Item} from '../src'; +import {Accordion, Disclosure, DisclosureHeader, DisclosurePanel} from '../src'; import {act, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; import {Provider} from '@react-spectrum/provider'; import React from 'react'; @@ -20,18 +20,19 @@ import userEvent from '@testing-library/user-event'; let items = [ {key: 'one', title: 'one title', children: 'one children'}, {key: 'two', title: 'two title', children: 'two children'}, - {key: 'three', title: 'three title', children: , hasChildItems: false} + {key: 'three', title: 'three title', children: } ]; function renderComponent(props) { return render( - - {item => ( - - {item.children} - - )} + + {items.map(item => ( + + {item.title} + {item.children} + + ))} ); @@ -67,45 +68,26 @@ describe('Accordion', function () { let tree = renderComponent(); let buttons = tree.getAllByRole('button'); let selectedItem = buttons[0]; - expect(selectedItem).toHaveAttribute('aria-expanded', 'true'); - await user.click(selectedItem); expect(selectedItem).toHaveAttribute('aria-expanded', 'false'); await user.click(selectedItem); expect(selectedItem).toHaveAttribute('aria-expanded', 'true'); + await user.click(selectedItem); + expect(selectedItem).toHaveAttribute('aria-expanded', 'false'); }); - it('allows users to open and close accordion item with enter / space key', async function () { + it('allows users to open and close disclosure with enter / space key', async function () { let tree = renderComponent(); let buttons = tree.getAllByRole('button'); let selectedItem = buttons[0]; - expect(selectedItem).toHaveAttribute('aria-expanded', 'true'); + expect(selectedItem).toHaveAttribute('aria-expanded', 'false'); act(() => {selectedItem.focus();}); expect(document.activeElement).toBe(selectedItem); - await user.keyboard('{Enter}'); - expect(selectedItem).toHaveAttribute('aria-expanded', 'false'); - await user.keyboard('{Enter}'); expect(selectedItem).toHaveAttribute('aria-expanded', 'true'); - }); - it('allows users to naviagte accordion headers through arrow keys', async function () { - let tree = renderComponent(); - let buttons = tree.getAllByRole('button'); - let [firstItem, secondItem, thirdItem] = buttons; - act(() => {firstItem.focus();}); - - expect(document.activeElement).toBe(firstItem); - await user.keyboard('{ArrowUp}'); - expect(document.activeElement).toBe(firstItem); - await user.keyboard('{ArrowDown}'); - expect(document.activeElement).toBe(secondItem); - await user.keyboard('{ArrowDown}'); - expect(document.activeElement).toBe(thirdItem); - await user.keyboard('{ArrowDown}'); - expect(document.activeElement).toBe(thirdItem); - await user.keyboard('{ArrowUp}'); - expect(document.activeElement).toBe(secondItem); + await user.keyboard('{Enter}'); + expect(selectedItem).toHaveAttribute('aria-expanded', 'false'); }); it('allows users to navigate accordion headers through the tab key', async function () { @@ -130,7 +112,7 @@ describe('Accordion', function () { expect(document.activeElement).toBe(thirdItem); }); - it('allows users to type inside accordion items', async function () { + it('allows users to type inside disclosures', async function () { let tree = renderComponent(); let buttons = tree.getAllByRole('button'); let itemWithInputHeader = buttons[2]; @@ -143,3 +125,5 @@ describe('Accordion', function () { expect(input.value).toEqual('Type example'); }); }); + + diff --git a/packages/@react-spectrum/s2/src/Accordion.tsx b/packages/@react-spectrum/s2/src/Accordion.tsx new file mode 100644 index 00000000000..2d32041a461 --- /dev/null +++ b/packages/@react-spectrum/s2/src/Accordion.tsx @@ -0,0 +1,84 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {ContextValue, Provider, SlotProps} from 'react-aria-components'; +import {DisclosureContext} from './Disclosure'; +import {DOMProps, DOMRef, DOMRefValue, forwardRefType} from '@react-types/shared'; +import {filterDOMProps} from '@react-aria/utils'; +import {getAllowedOverrides, StylesPropWithHeight, UnsafeStyles} from './style-utils' with { type: 'macro' }; +import React, {createContext, forwardRef} from 'react'; +import {style} from '../style/spectrum-theme' with { type: 'macro' }; +import {useDOMRef} from '@react-spectrum/utils'; +import {useSpectrumContextProps} from './useSpectrumContextProps'; + +export interface AccordionProps extends UnsafeStyles, DOMProps, SlotProps { + /** The disclosure elements in the accordion. */ + children: React.ReactNode, + /** Spectrum-defined styles, returned by the `style()` macro. */ + styles?: StylesPropWithHeight, + /** + * The size of the accordion. + * @default "M" + */ + size?: 'S' | 'M' | 'L' | 'XL', + /** + * The amount of space between the disclosure items. + * @default "regular" + */ + density?: 'compact' | 'regular' | 'spacious', + /** Whether the accordion should be displayed with a quiet style. */ + isQuiet?: boolean, + /** Whether the accordion should be disabled. */ + isDisabled?: boolean +} + +const accordion = style({ + display: 'flex', + flexDirection: 'column' +}, getAllowedOverrides({height: true})); + +export const AccordionContext = createContext>>(null); + +function Accordion(props: AccordionProps, ref: DOMRef) { + [props, ref] = useSpectrumContextProps(props, ref, AccordionContext); + let domRef = useDOMRef(ref); + let { + UNSAFE_style, + UNSAFE_className = '', + size = 'M', + density = 'regular', + isQuiet, + isDisabled, + ...otherProps + } = props; + const domProps = filterDOMProps(otherProps); + return ( + +
+ {props.children} +
+
+ ); +} + +/** + * An accordion is a container for multiple disclosures. + */ +let _Accordion = /*#__PURE__*/ (forwardRef as forwardRefType)(Accordion); +export {_Accordion as Accordion}; diff --git a/packages/@react-spectrum/s2/src/Disclosure.tsx b/packages/@react-spectrum/s2/src/Disclosure.tsx new file mode 100644 index 00000000000..cadb71d5430 --- /dev/null +++ b/packages/@react-spectrum/s2/src/Disclosure.tsx @@ -0,0 +1,291 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {AriaLabelingProps, DOMProps, DOMRef, DOMRefValue, forwardRefType} from '@react-types/shared'; +import {Button, ContextValue, DisclosureStateContext, Heading, Provider, Disclosure as RACDisclosure, DisclosurePanel as RACDisclosurePanel, DisclosurePanelProps as RACDisclosurePanelProps, DisclosureProps as RACDisclosureProps, useLocale, useSlottedContext} from 'react-aria-components'; +import {CenterBaseline} from './CenterBaseline'; +import {centerPadding, focusRing, getAllowedOverrides, StyleProps, UnsafeStyles} from './style-utils' with { type: 'macro' }; +import Chevron from '../ui-icons/Chevron'; +import {filterDOMProps} from '@react-aria/utils'; +import React, {createContext, forwardRef, ReactElement, useContext} from 'react'; +import {size as sizeValue, style} from '../style/spectrum-theme' with { type: 'macro' }; +import {useDOMRef} from '@react-spectrum/utils'; +import {useSpectrumContextProps} from './useSpectrumContextProps'; + + +export interface DisclosureProps extends RACDisclosureProps, StyleProps, DOMProps { + /** + * The size of the disclosure. + * @default "M" + */ + size?: 'S' | 'M' | 'L' | 'XL', + /** + * The amount of space between the disclosures. + * @default "regular" + */ + density?: 'compact' | 'regular' | 'spacious', + /** Whether the disclosure should be displayed with a quiet style. */ + isQuiet?: boolean, + /** The contents of the disclosure, consisting of an DisclosureHeader and DisclosurePanel. */ + children: [ReactElement, ReactElement] +} + +export const DisclosureContext = createContext, DOMRefValue>>(null); + +const disclosure = style({ + color: 'heading', + borderTopWidth: { + default: 1, + isQuiet: 0 + }, + borderBottomWidth: { + default: 0, + ':last-child': { + default: 1, + isQuiet: 0 + } + }, + borderStartWidth: 0, + borderEndWidth: 0, + borderStyle: 'solid', + borderColor: 'gray-200', + minWidth: sizeValue(200) +}, getAllowedOverrides()); + +function Disclosure(props: DisclosureProps, ref: DOMRef) { + [props, ref] = useSpectrumContextProps(props, ref, DisclosureContext); + let { + size = 'M', + density = 'regular', + isQuiet, isDisabled + } = props; + let domRef = useDOMRef(ref); + let { + UNSAFE_style, + UNSAFE_className = '', + ...otherProps + } = props; + const domProps = filterDOMProps(otherProps); + + return ( + + + {props.children} + + + ); +} + +/** + * A disclosure is a collapsible section of content. It is composed of a a header with a heading and trigger button, and a panel that contains the content. + */ +let _Disclosure = /*#__PURE__*/ (forwardRef as forwardRefType)(Disclosure); +export {_Disclosure as Disclosure}; + +export interface DisclosureHeaderProps extends UnsafeStyles, DOMProps { + /** The heading level of the disclosure header. + * + * @default 3 + */ + level?: number, + children: React.ReactNode +} + +const headingStyle = style({ + margin: 0 +}); + +const buttonStyles = style({ + ...focusRing(), + outlineOffset: -2, + font: 'heading', + color: { + default: 'neutral', + isDisabled: 'disabled' + }, + fontWeight: 'bold', + fontSize: { + size: { + S: 'heading-xs', + M: 'heading-sm', + L: 'heading', + XL: 'heading-lg' + } + }, + lineHeight: 'ui', + display: 'flex', + alignItems: 'baseline', + paddingX: '[calc(self(minHeight) * 3/8 - 1px)]', + paddingY: centerPadding(), + gap: '[calc(self(minHeight) * 3/8 - 1px)]', + minHeight: { + // compact is equivalent to 'control', but other densities have more padding. + size: { + S: { + density: { + compact: 24, + regular: 32, + spacious: 40 + } + }, + M: { + density: { + compact: 32, + regular: 40, + spacious: 48 + } + }, + L: { + density: { + compact: 40, + regular: 48, + spacious: 56 + } + }, + XL: { + density: { + compact: 48, + regular: 56, + spacious: 64 + } + } + } + }, + width: 'full', + backgroundColor: { + default: 'transparent', + isFocusVisible: 'transparent-black-100', + isHovered: 'transparent-black-100' + }, + borderWidth: 0, + borderRadius: { + // Only rounded for keyboard focus and quiet hover. + default: 'none', + isFocusVisible: 'control', + isQuiet: { + isHovered: 'control', + isFocusVisible: 'control' + } + }, + textAlign: 'start', + disableTapHighlight: true +}); + +const chevronStyles = style({ + rotate: { + isRTL: 180, + isExpanded: 90 + }, + transitionDuration: '100ms', + transitionProperty: 'rotate', + '--iconPrimary': { + type: 'fill', + value: 'currentColor' + }, + flexShrink: 0 +}); + +function DisclosureHeader(props: DisclosureHeaderProps, ref: DOMRef) { + let { + level = 3, + UNSAFE_style, + UNSAFE_className = '', + ...otherProps + } = props; + let domRef = useDOMRef(ref); + const domProps = filterDOMProps(otherProps); + let {direction} = useLocale(); + let {isExpanded} = useContext(DisclosureStateContext)!; + let {size, density, isQuiet} = useSlottedContext(DisclosureContext)!; + let isRTL = direction === 'rtl'; + return ( + + + + ); +} + +/** + * A header for a disclosure. Contains a heading and a trigger button to expand/collapse the panel. + */ +let _DisclosureHeader = /*#__PURE__*/ (forwardRef as forwardRefType)(DisclosureHeader); +export {_DisclosureHeader as DisclosureHeader}; + +export interface DisclosurePanelProps extends RACDisclosurePanelProps, UnsafeStyles, DOMProps, AriaLabelingProps { + children: React.ReactNode +} + +const panelStyles = style({ + font: 'body', + paddingTop: { + isExpanded: 8 + }, + paddingBottom: { + isExpanded: 16 + }, + paddingX: { + isExpanded: { + size: { + S: 8, + M: sizeValue(9), + L: 12, + XL: sizeValue(15) + } + } + } +}); + +function DisclosurePanel(props: DisclosurePanelProps, ref: DOMRef) { + let { + UNSAFE_style, + UNSAFE_className = '', + ...otherProps + } = props; + const domProps = filterDOMProps(otherProps); + let {size} = useSlottedContext(DisclosureContext)!; + let {isExpanded} = useContext(DisclosureStateContext)!; + let panelRef = useDOMRef(ref); + return ( + + {props.children} + + ); +} + +/** + * A disclosure panel is a collapsible section of content that is hidden until the disclosure is expanded. + */ +let _DisclosurePanel = /*#__PURE__*/ (forwardRef as forwardRefType)(DisclosurePanel); +export {_DisclosurePanel as DisclosurePanel}; + diff --git a/packages/@react-spectrum/s2/src/index.ts b/packages/@react-spectrum/s2/src/index.ts index 4b9ad5f13ef..77f9e2eaa53 100644 --- a/packages/@react-spectrum/s2/src/index.ts +++ b/packages/@react-spectrum/s2/src/index.ts @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ +export {Accordion, AccordionContext} from './Accordion'; export {ActionButton, ActionButtonContext} from './ActionButton'; export {ActionMenu, ActionMenuContext} from './ActionMenu'; export {AlertDialog} from './AlertDialog'; @@ -31,6 +32,7 @@ export {ColorSwatchPicker, ColorSwatchPickerContext} from './ColorSwatchPicker'; export {ColorWheel, ColorWheelContext} from './ColorWheel'; export {ComboBox, ComboBoxItem, ComboBoxSection, ComboBoxContext} from './ComboBox'; export {ContextualHelp, ContextualHelpContext} from './ContextualHelp'; +export {DisclosureHeader, Disclosure, DisclosurePanel, DisclosureContext} from './Disclosure'; export {Heading, HeadingContext, Header, HeaderContext, Content, ContentContext, Footer, FooterContext, Text, TextContext, Keyboard, KeyboardContext} from './Content'; export {Dialog} from './Dialog'; export {DialogTrigger} from './DialogTrigger'; @@ -70,6 +72,7 @@ export {Tooltip, TooltipTrigger} from './Tooltip'; export {Collection} from 'react-aria-components'; export {FileTrigger} from 'react-aria-components'; +export type {AccordionProps} from './Accordion'; export type {ActionButtonProps} from './ActionButton'; export type {ActionMenuProps} from './ActionMenu'; export type {AlertDialogProps} from './AlertDialog'; @@ -93,6 +96,7 @@ export type {ComboBoxProps, ComboBoxItemProps, ComboBoxSectionProps} from './Com export type {DialogProps} from './Dialog'; export type {DialogContainerProps, DialogContainerValue} from './DialogContainer'; export type {DialogTriggerProps} from './DialogTrigger'; +export type {DisclosureProps, DisclosurePanelProps} from './Disclosure'; export type {DividerProps} from './Divider'; export type {DropZoneProps} from './DropZone'; export type {FormProps} from './Form'; diff --git a/packages/@react-spectrum/s2/stories/Accordion.stories.tsx b/packages/@react-spectrum/s2/stories/Accordion.stories.tsx new file mode 100644 index 00000000000..db513a9b1de --- /dev/null +++ b/packages/@react-spectrum/s2/stories/Accordion.stories.tsx @@ -0,0 +1,145 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {Accordion, Disclosure, DisclosureHeader, DisclosurePanel, TextField} from '../src'; +import type {Meta, StoryObj} from '@storybook/react'; +import React from 'react'; +import {style} from '../style/spectrum-theme' with { type: 'macro' }; + +const meta: Meta = { + component: Accordion, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + argTypes: { + size: { + control: 'radio', + options: ['S', 'M', 'L', 'XL'] + }, + isQuiet: { + control: {type: 'boolean'} + }, + density: { + control: 'radio', + options: ['compact', 'regular', 'spacious'] + }, + isDisabled: { + control: {type: 'boolean'} + } + } +}; + +export default meta; +type Story = StoryObj; + +export const Example: Story = { + render: (args) => { + return ( +
+ + + + Files + + + Files content + + + + + People + + + + + + +
+ ); + } +}; + +export const WithLongTitle: Story = { + render: (args) => { + return ( +
+ + + + Files + + + Files content + + + + + People + + + People content + + + + + Very very very very very long title that wraps + + + Accordion content + + + +
+ ); + } +}; + +export const WithDisabledDisclosure: Story = { + render: (args) => { + return ( +
+ + + + Files + + + Files content + + + + + People + + + + + + +
+ ); + } +}; + +WithLongTitle.parameters = { + docs: { + disable: true + } +}; + +WithDisabledDisclosure.parameters = { + docs: { + disable: true + } +}; + diff --git a/packages/@react-spectrum/s2/stories/Disclosure.stories.tsx b/packages/@react-spectrum/s2/stories/Disclosure.stories.tsx new file mode 100644 index 00000000000..ed23e5984ca --- /dev/null +++ b/packages/@react-spectrum/s2/stories/Disclosure.stories.tsx @@ -0,0 +1,84 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {Disclosure, DisclosureHeader, DisclosurePanel} from '../src'; +import type {Meta, StoryObj} from '@storybook/react'; +import React from 'react'; +import {style} from '../style/spectrum-theme' with { type: 'macro' }; + +const meta: Meta = { + component: Disclosure, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + argTypes: { + size: { + control: 'radio', + options: ['S', 'M', 'L', 'XL'] + }, + isQuiet: { + control: {type: 'boolean'} + }, + density: { + control: 'radio', + options: ['compact', 'regular', 'spacious'] + }, + isDisabled: { + control: {type: 'boolean'} + } + } +}; + +export default meta; +type Story = StoryObj; + +export const Example: Story = { + render: (args) => { + return ( +
+ + + Files + + + Files content + + +
+ ); + } +}; + +export const WithLongTitle: Story = { + render: (args) => { + return ( +
+ + + Very very very very very long title that wraps + + + Content + + +
+ ); + } +}; + + +WithLongTitle.parameters = { + docs: { + disable: true + } +}; diff --git a/packages/@react-stately/disclosure/README.md b/packages/@react-stately/disclosure/README.md new file mode 100644 index 00000000000..225e353737d --- /dev/null +++ b/packages/@react-stately/disclosure/README.md @@ -0,0 +1,3 @@ +# @react-stately/disclosure + +This package is part of [react-spectrum](https://github.com/adobe/react-spectrum). See the repo for more details. diff --git a/packages/@react-stately/disclosure/index.ts b/packages/@react-stately/disclosure/index.ts new file mode 100644 index 00000000000..dc59658a5da --- /dev/null +++ b/packages/@react-stately/disclosure/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +export * from './src'; diff --git a/packages/@react-stately/disclosure/package.json b/packages/@react-stately/disclosure/package.json new file mode 100644 index 00000000000..bff33abfb1c --- /dev/null +++ b/packages/@react-stately/disclosure/package.json @@ -0,0 +1,35 @@ +{ + "name": "@react-stately/disclosure", + "version": "3.0.0-alpha.0", + "description": "Spectrum UI components in React", + "license": "Apache-2.0", + "main": "dist/main.js", + "module": "dist/module.js", + "exports": { + "types": "./dist/types.d.ts", + "import": "./dist/import.mjs", + "require": "./dist/main.js" + }, + "types": "dist/types.d.ts", + "source": "src/index.ts", + "files": [ + "dist", + "src" + ], + "sideEffects": false, + "repository": { + "type": "git", + "url": "https://github.com/adobe/react-spectrum" + }, + "dependencies": { + "@react-stately/utils": "^3.10.3", + "@react-types/shared": "^3.24.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/@react-stately/disclosure/src/index.ts b/packages/@react-stately/disclosure/src/index.ts new file mode 100644 index 00000000000..c9497fda8ac --- /dev/null +++ b/packages/@react-stately/disclosure/src/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +export {useDisclosureState} from './useDisclosureState'; + +export type {DisclosureState, DisclosureProps} from './useDisclosureState'; diff --git a/packages/@react-stately/disclosure/src/useDisclosureState.ts b/packages/@react-stately/disclosure/src/useDisclosureState.ts new file mode 100644 index 00000000000..c7b0d14b94c --- /dev/null +++ b/packages/@react-stately/disclosure/src/useDisclosureState.ts @@ -0,0 +1,65 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {useCallback} from 'react'; +import {useControlledState} from '@react-stately/utils'; + +export interface DisclosureProps { + /** Whether the disclosure is expanded (controlled). */ + isExpanded?: boolean, + /** Whether the disclosure is expanded by default (uncontrolled). */ + defaultExpanded?: boolean, + /** Handler that is called when the disclosure expanded state changes. */ + onExpandedChange?: (isExpanded: boolean) => void +} + + +export interface DisclosureState { + /** Whether the disclosure is currently expanded. */ + readonly isExpanded: boolean, + /** Sets whether the disclosure is expanded. */ + setExpanded(isExpanded: boolean): void, + /** Expand the disclosure. */ + expand(): void, + /** Collapse the disclosure. */ + collapse(): void, + /** Toggles the disclosure's visibility. */ + toggle(): void +} + +/** + * Manages state for a disclosure widget. Tracks whether the disclosure is expanded, and provides + * methods to toggle this state. + */ +export function useDisclosureState(props: DisclosureProps): DisclosureState { + let [isExpanded, setExpanded] = useControlledState(props.isExpanded, props.defaultExpanded || false, props.onExpandedChange); + + const expand = useCallback(() => { + setExpanded(true); + }, [setExpanded]); + + const collapse = useCallback(() => { + setExpanded(false); + }, [setExpanded]); + + const toggle = useCallback(() => { + setExpanded(!isExpanded); + }, [setExpanded, isExpanded]); + + return { + isExpanded, + setExpanded, + expand, + collapse, + toggle + }; +} diff --git a/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/multi-collection.test.ts.snap b/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/multi-collection.test.ts.snap index d5d02551ae8..1e78a4964fa 100644 --- a/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/multi-collection.test.ts.snap +++ b/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/multi-collection.test.ts.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Does not affect unimplemented collections 1`] = ` -"import {Accordion, Item, ActionBarContainer, ActionBar, ListView, ListBox} from '@adobe/react-spectrum'; +"import {Item, ActionBarContainer, ActionBar, ListView, ListBox} from '@adobe/react-spectrum'; import {SearchAutocomplete} from '@react-spectrum/autocomplete'; import {StepList} from '@react-spectrum/steplist'; @@ -11,11 +11,6 @@ import {StepList} from '@react-spectrum/steplist'; Two Three - - One - Two - Three - Adobe Photoshop diff --git a/packages/dev/codemods/src/s1-to-s2/__tests__/multi-collection.test.ts b/packages/dev/codemods/src/s1-to-s2/__tests__/multi-collection.test.ts index ea8cceb8261..1be45f00987 100644 --- a/packages/dev/codemods/src/s1-to-s2/__tests__/multi-collection.test.ts +++ b/packages/dev/codemods/src/s1-to-s2/__tests__/multi-collection.test.ts @@ -40,7 +40,7 @@ import {Breadcrumbs, Item, Menu, MenuTrigger, SubmenuTrigger, Button, Section, H `); test('Does not affect unimplemented collections', ` -import {Accordion, Item, ActionBarContainer, ActionBar, ListView, ListBox} from '@adobe/react-spectrum'; +import {Item, ActionBarContainer, ActionBar, ListView, ListBox} from '@adobe/react-spectrum'; import {SearchAutocomplete} from '@react-spectrum/autocomplete'; import {StepList} from '@react-spectrum/steplist'; @@ -50,11 +50,6 @@ import {StepList} from '@react-spectrum/steplist'; Two Three - - One - Two - Three - Adobe Photoshop diff --git a/packages/react-aria-components/package.json b/packages/react-aria-components/package.json index a3bd84757b8..272ceed2f0c 100644 --- a/packages/react-aria-components/package.json +++ b/packages/react-aria-components/package.json @@ -39,8 +39,10 @@ "dependencies": { "@internationalized/date": "^3.5.5", "@internationalized/string": "^3.2.3", + "@react-aria/accordion": "3.0.0-alpha.33", "@react-aria/collections": "3.0.0-alpha.4", "@react-aria/color": "3.0.0-rc.2", + "@react-aria/disclosure": "3.0.0-alpha.0", "@react-aria/dnd": "^3.7.2", "@react-aria/focus": "^3.18.2", "@react-aria/interactions": "^3.22.2", @@ -51,6 +53,7 @@ "@react-aria/utils": "^3.25.2", "@react-aria/virtualizer": "^4.0.2", "@react-stately/color": "^3.7.2", + "@react-stately/disclosure": "3.0.0-alpha.0", "@react-stately/layout": "^4.0.2", "@react-stately/menu": "^3.8.2", "@react-stately/table": "^3.12.2", diff --git a/packages/react-aria-components/src/Disclosure.tsx b/packages/react-aria-components/src/Disclosure.tsx new file mode 100644 index 00000000000..9a08a7a3863 --- /dev/null +++ b/packages/react-aria-components/src/Disclosure.tsx @@ -0,0 +1,150 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {AriaDisclosureProps, useDisclosure} from '@react-aria/disclosure'; +import {ButtonContext} from './Button'; +import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, SlotProps, useContextProps, useRenderProps} from './utils'; +import {DisclosureState, useDisclosureState} from '@react-stately/disclosure'; +import {forwardRefType} from '@react-types/shared'; +import {HoverEvents, useFocusRing} from 'react-aria'; +import {mergeProps, mergeRefs} from '@react-aria/utils'; +import React, {createContext, DOMAttributes, ForwardedRef, forwardRef, ReactNode, useContext} from 'react'; + +export interface DisclosureProps extends Omit, HoverEvents, RenderProps, SlotProps {} + +export interface DisclosureRenderProps { + /** + * Whether the disclosure is expanded. + * @selector [data-expanded] + */ + isExpanded: boolean, + /** + * Whether the disclosure has keyboard focus. + * @selector [data-focus-visible-within] + */ + isFocusVisibleWithin: boolean, + /** + * Whether the disclosure is disabled. + * @selector [data-disabled] + */ + isDisabled: boolean, + /** + * State of the disclosure. + */ + state: DisclosureState +} + +export const DisclosureContext = createContext>(null); +export const DisclosureStateContext = createContext(null); + +interface InternalDisclosureContextValue { + contentProps: DOMAttributes, + contentRef: React.RefObject +} + +const InternalDisclosureContext = createContext(null); + +function Disclosure(props: DisclosureProps, ref: ForwardedRef) { + [props, ref] = useContextProps(props, ref, DisclosureContext); + let state = useDisclosureState(props); + let contentRef = React.useRef(null); + let {buttonProps, contentProps} = useDisclosure(props, state, contentRef); + let { + isFocusVisible: isFocusVisibleWithin, + focusProps: focusWithinProps + } = useFocusRing({within: true}); + + let renderProps = useRenderProps({ + ...props, + defaultClassName: 'react-aria-Disclosure', + values: { + isExpanded: state.isExpanded, + isDisabled: props.isDisabled || false, + isFocusVisibleWithin, + state + } + }); + + return ( + +
+ {renderProps.children} +
+
+ ); +} + +export interface DisclosurePanelProps extends RenderProps<{}> { + /** + * The accessibility role for the disclosure's panel. + * @default 'group' + */ + role?: 'group' | 'region', + children: ReactNode +} + +function DisclosurePanel(props: DisclosurePanelProps, ref: ForwardedRef) { + let {role = 'group'} = props; + let {contentProps, contentRef} = useContext(InternalDisclosureContext)!; + let { + isFocusVisible: isFocusVisibleWithin, + focusProps: focusWithinProps + } = useFocusRing({within: true}); + let renderProps = useRenderProps({ + ...props, + defaultClassName: 'react-aria-DisclosurePanel', + values: { + isFocusVisibleWithin + } + }); + return ( +
+ + {props.children} + +
+ ); +} + +/** + * A disclosure is a collapsible section of content. It is composed of a a header with a heading and trigger button, and a panel that contains the content. + */ +const _Disclosure = /*#__PURE__*/ (forwardRef as forwardRefType)(Disclosure); +export {_Disclosure as Disclosure}; + +const _DisclosurePanel = /*#__PURE__*/ (forwardRef as forwardRefType)(DisclosurePanel); +export {_DisclosurePanel as DisclosurePanel}; diff --git a/packages/react-aria-components/src/index.ts b/packages/react-aria-components/src/index.ts index 7f6c3f03c73..df2261e62ac 100644 --- a/packages/react-aria-components/src/index.ts +++ b/packages/react-aria-components/src/index.ts @@ -16,6 +16,7 @@ import 'client-only'; export {CheckboxContext, ColorAreaContext, ColorFieldContext, ColorSliderContext, ColorWheelContext, HeadingContext} from './RSPContexts'; +export {Disclosure, DisclosurePanel, DisclosureStateContext, DisclosureContext} from './Disclosure'; export {Breadcrumbs, BreadcrumbsContext, Breadcrumb} from './Breadcrumbs'; export {Button, ButtonContext} from './Button'; export {Calendar, CalendarGrid, CalendarGridHeader, CalendarGridBody, CalendarHeaderCell, CalendarCell, RangeCalendar, CalendarContext, RangeCalendarContext, CalendarStateContext, RangeCalendarStateContext} from './Calendar'; @@ -80,6 +81,7 @@ export {FormValidationContext} from 'react-stately'; export {parseColor, getColorChannels} from '@react-stately/color'; export {ListLayout as UNSTABLE_ListLayout, GridLayout as UNSTABLE_GridLayout} from '@react-stately/layout'; +export type {DisclosureProps, DisclosurePanelProps} from './Disclosure'; export type {BreadcrumbsProps, BreadcrumbProps, BreadcrumbRenderProps} from './Breadcrumbs'; export type {ButtonProps, ButtonRenderProps} from './Button'; export type {CalendarCellProps, CalendarProps, CalendarRenderProps, CalendarGridProps, CalendarGridHeaderProps, CalendarGridBodyProps, CalendarHeaderCellProps, CalendarCellRenderProps, RangeCalendarProps, RangeCalendarRenderProps} from './Calendar'; diff --git a/packages/react-aria-components/stories/Disclosure.stories.tsx b/packages/react-aria-components/stories/Disclosure.stories.tsx new file mode 100644 index 00000000000..bf1c9109b87 --- /dev/null +++ b/packages/react-aria-components/stories/Disclosure.stories.tsx @@ -0,0 +1,54 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {Button, Heading} from 'react-aria-components'; +import {Disclosure, DisclosurePanel} from '../src'; +import React from 'react'; +import './styles.css'; + +export default { + title: 'React Aria Components', + component: Disclosure +}; + +export const DisclosureExample = (args: any) => ( + + {({isExpanded}) => ( + <> + + + + +

This is the content of the disclosure panel.

+
+ + )} +
+); + +export const DisclosureControlledExample = (args: any) => { + let [isExpanded, setExpanded] = React.useState(false); + return ( + + {({isExpanded}) => ( + <> + + + + +

This is the content of the disclosure panel.

+
+ + )} +
+ ); +}; diff --git a/packages/react-aria-components/stories/styles.css b/packages/react-aria-components/stories/styles.css index 11a4eca25ce..451d50eb9ba 100644 --- a/packages/react-aria-components/stories/styles.css +++ b/packages/react-aria-components/stories/styles.css @@ -387,3 +387,14 @@ height: 100%; } } + +:global(.react-aria-Disclosure) { + :global(.react-aria-Button[slot=trigger]) { + background-color: transparent; + border: none; + } + :global(.react-aria-Header) { + display: flex; + align-items: center + } +} diff --git a/tsconfig.json b/tsconfig.json index bc6ffbb927c..cdd5a05a482 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -47,6 +47,7 @@ "./packages/@react-aria/color", "./packages/@react-aria/collections", "./packages/@react-aria/dialog", + "./packages/@react-aria/disclosure", "./packages/@react-aria/e", "./packages/@react-aria/f", "./packages/@react-aria/h", @@ -116,6 +117,7 @@ "./packages/@react-stately/checkbox", "./packages/@react-stately/color", "./packages/@react-stately/combobox", + "./packages/@react-stately/disclosure", "./packages/@react-stately/list", "./packages/@react-stately/numberfield", "./packages/@react-stately/overlays", diff --git a/yarn.lock b/yarn.lock index 6e92651960e..49f71791b1f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5794,6 +5794,26 @@ __metadata: languageName: unknown linkType: soft +"@react-aria/disclosure@npm:3.0.0-alpha.0, @react-aria/disclosure@workspace:packages/@react-aria/disclosure": + version: 0.0.0-use.local + resolution: "@react-aria/disclosure@workspace:packages/@react-aria/disclosure" + dependencies: + "@react-aria/button": "npm:^3.9.8" + "@react-aria/selection": "npm:^3.19.3" + "@react-aria/ssr": "npm:^3.9.5" + "@react-aria/utils": "npm:^3.25.2" + "@react-stately/disclosure": "npm:3.0.0-alpha.0" + "@react-stately/toggle": "npm:^3.7.7" + "@react-stately/tree": "npm:^3.8.4" + "@react-types/button": "npm:^3.9.6" + "@react-types/shared": "npm:^3.24.1" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + languageName: unknown + linkType: soft + "@react-aria/dnd@npm:^3.0.0, @react-aria/dnd@npm:^3.1.0, @react-aria/dnd@npm:^3.7.1, @react-aria/dnd@npm:^3.7.2, @react-aria/dnd@workspace:packages/@react-aria/dnd": version: 0.0.0-use.local resolution: "@react-aria/dnd@workspace:packages/@react-aria/dnd" @@ -6504,10 +6524,10 @@ __metadata: "@react-spectrum/utils": "npm:^3.11.9" "@react-stately/collections": "npm:^3.10.9" "@react-stately/tree": "npm:^3.8.3" - "@react-types/accordion": "npm:3.0.0-alpha.23" "@react-types/shared": "npm:^3.24.1" "@spectrum-icons/ui": "npm:^3.6.9" "@swc/helpers": "npm:^0.5.0" + react-aria-components: "npm:^1.3.3" peerDependencies: "@react-spectrum/provider": ^3.0.0 react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 @@ -8151,6 +8171,18 @@ __metadata: languageName: unknown linkType: soft +"@react-stately/disclosure@npm:3.0.0-alpha.0, @react-stately/disclosure@workspace:packages/@react-stately/disclosure": + version: 0.0.0-use.local + resolution: "@react-stately/disclosure@workspace:packages/@react-stately/disclosure" + dependencies: + "@react-stately/utils": "npm:^3.10.3" + "@react-types/shared": "npm:^3.24.1" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + languageName: unknown + linkType: soft + "@react-stately/dnd@npm:^3.0.0, @react-stately/dnd@npm:^3.1.0, @react-stately/dnd@npm:^3.4.1, @react-stately/dnd@npm:^3.4.2, @react-stately/dnd@workspace:packages/@react-stately/dnd": version: 0.0.0-use.local resolution: "@react-stately/dnd@workspace:packages/@react-stately/dnd" @@ -28715,8 +28747,10 @@ __metadata: dependencies: "@internationalized/date": "npm:^3.5.5" "@internationalized/string": "npm:^3.2.3" + "@react-aria/accordion": "npm:3.0.0-alpha.33" "@react-aria/collections": "npm:3.0.0-alpha.4" "@react-aria/color": "npm:3.0.0-rc.2" + "@react-aria/disclosure": "npm:3.0.0-alpha.0" "@react-aria/dnd": "npm:^3.7.2" "@react-aria/focus": "npm:^3.18.2" "@react-aria/interactions": "npm:^3.22.2" @@ -28727,6 +28761,7 @@ __metadata: "@react-aria/utils": "npm:^3.25.2" "@react-aria/virtualizer": "npm:^4.0.2" "@react-stately/color": "npm:^3.7.2" + "@react-stately/disclosure": "npm:3.0.0-alpha.0" "@react-stately/layout": "npm:^4.0.2" "@react-stately/menu": "npm:^3.8.2" "@react-stately/table": "npm:^3.12.2"