From 3c61fff49e55d5aa44e6785214a06a6d055e95d6 Mon Sep 17 00:00:00 2001 From: Jon Cullison Date: Thu, 3 Apr 2025 13:28:32 -0400 Subject: [PATCH 1/7] improves accessibility --- assets/index.less | 18 +++-- src/Collapse.tsx | 7 ++ src/Panel.tsx | 43 ++++++++---- src/PanelContent.tsx | 3 + src/hooks/useItems.tsx | 22 +++++- src/interface.ts | 7 ++ tests/index.spec.tsx | 155 +++++++++++++++++++++++++++++++++++++++-- 7 files changed, 232 insertions(+), 23 deletions(-) diff --git a/assets/index.less b/assets/index.less index 4093e0f..f53801e 100644 --- a/assets/index.less +++ b/assets/index.less @@ -39,7 +39,8 @@ border-top: none; } - > .@{prefixCls}-header { + > .@{prefixCls}-header, + > :is(h1, h2, h3, h4, h5, h6) > .@{prefixCls}-header { display: flex; align-items: center; line-height: 22px; @@ -76,10 +77,14 @@ } } - & > &-item-disabled > .@{prefixCls}-header { - cursor: not-allowed; - color: #999; - background-color: #f3f3f3; + + & > &-item-disabled { + > .@{prefixCls}-header, + > :is(h1,h2,h3,h4,h5,h6) > .@{prefixCls}-header { + cursor: not-allowed; + color: #999; + background-color: #f3f3f3; + } } &-panel { @@ -105,7 +110,8 @@ } & > &-item-active { - > .@{prefixCls}-header { + > .@{prefixCls}-header, + > :is(h1,h2,h3,h4,h5,h6) > .@{prefixCls}-header { .arrow { position: relative; top: 2px; diff --git a/src/Collapse.tsx b/src/Collapse.tsx index e75ec5f..95a2623 100644 --- a/src/Collapse.tsx +++ b/src/Collapse.tsx @@ -34,6 +34,9 @@ const Collapse = React.forwardRef((props, ref) => items, classNames: customizeClassNames, styles, + panelContentRole, + headingLevel, + id, } = props; const collapseClassName = classNames(prefixCls, className); @@ -77,6 +80,9 @@ const Collapse = React.forwardRef((props, ref) => activeKey, classNames: customizeClassNames, styles, + contentRole: panelContentRole, + headingLevel, + id: id, }); // ======================== Render ======================== @@ -87,6 +93,7 @@ const Collapse = React.forwardRef((props, ref) => style={style} role={accordion ? 'tablist' : undefined} {...pickAttrs(props, { aria: true, data: true })} + id={id} > {mergedChildren} diff --git a/src/Panel.tsx b/src/Panel.tsx index c463325..fe97a3d 100644 --- a/src/Panel.tsx +++ b/src/Panel.tsx @@ -1,6 +1,7 @@ import classNames from 'classnames'; import CSSMotion from 'rc-motion'; import KeyCode from '@rc-component/util/lib/KeyCode'; +import type { PropsWithChildren } from 'react'; import React from 'react'; import type { CollapsePanelProps } from './interface'; import PanelContent from './PanelContent'; @@ -25,6 +26,9 @@ const CollapsePanel = React.forwardRef((prop openMotion, destroyInactivePanel, children, + contentRole, + headingLevel, + id, ...resetProps } = props; @@ -85,20 +89,33 @@ const CollapsePanel = React.forwardRef((prop ...(['header', 'icon'].includes(collapsible) ? {} : collapsibleProps), }; + const HeaderWrapper = ({ children: headerWrapperChildren }: PropsWithChildren) => { + if (!headingLevel) { + return <>{headerWrapperChildren}; + } + return React.createElement(headingLevel, { style: { all: 'unset' } }, headerWrapperChildren); + }; + // ======================== Render ======================== return ( -
-
- {showArrow && iconNode} - + +
- {header} - - {ifExtraExist &&
{extra}
} -
+ {showArrow && iconNode} + + {header} + + {ifExtraExist &&
{extra}
} +
+ ((prop return ( ((prop styles={styles} isActive={isActive} forceRender={forceRender} - role={accordion ? 'tabpanel' : void 0} + role={contentRole ? contentRole : accordion ? 'tabpanel' : void 0} > {children} diff --git a/src/PanelContent.tsx b/src/PanelContent.tsx index e5fc2fc..d0b3681 100644 --- a/src/PanelContent.tsx +++ b/src/PanelContent.tsx @@ -16,6 +16,7 @@ const PanelContent = React.forwardRef< role, classNames: customizeClassNames, styles, + id, } = props; const [rendered, setRendered] = React.useState(isActive || forceRender); @@ -33,6 +34,8 @@ const PanelContent = React.forwardRef< return (
& Pick & { activeKey: React.Key[]; @@ -23,6 +31,9 @@ const convertItemsToNodes = (items: ItemType[], props: Props) => { expandIcon, classNames: collapseClassNames, styles, + contentRole, + headingLevel, + id, } = props; return items.map((item, index) => { @@ -71,6 +82,9 @@ const convertItemsToNodes = (items: ItemType[], props: Props) => { collapsible={mergeCollapsible} onItemClick={handleItemClick} destroyInactivePanel={mergeDestroyInactivePanel} + contentRole={contentRole} + headingLevel={headingLevel} + id={id ? `${id}__item-${index}` : undefined} > {children} @@ -99,6 +113,9 @@ const getNewChild = ( expandIcon, classNames: collapseClassNames, styles, + contentRole, + headingLevel, + id, } = props; const key = child.key || String(index); @@ -142,6 +159,9 @@ const getNewChild = ( onItemClick: handleItemClick, expandIcon, collapsible: mergeCollapsible, + contentRole, + headingLevel, + id: id ? `${id}__item-${index}` : undefined, }; // https://github.com/ant-design/ant-design/issues/20479 diff --git a/src/interface.ts b/src/interface.ts index 123c793..09b95e2 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -2,6 +2,8 @@ import type { CSSMotionProps } from 'rc-motion'; import type * as React from 'react'; export type CollapsibleType = 'header' | 'icon' | 'disabled'; +export type HeadingLevelType = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; +export type PanelContentRoleType = 'region' | 'none'; export interface ItemType extends Omit< @@ -39,6 +41,9 @@ export interface CollapseProps { items?: ItemType[]; classNames?: Partial>; styles?: Partial>; + headingLevel?: HeadingLevelType; + panelContentRole?: PanelContentRoleType; + id?: string; } export type SemanticName = 'header' | 'title' | 'body' | 'icon'; @@ -64,4 +69,6 @@ export interface CollapsePanelProps extends React.DOMAttributes role?: string; collapsible?: CollapsibleType; children?: React.ReactNode; + contentRole?: PanelContentRoleType; + headingLevel?: HeadingLevelType; } diff --git a/tests/index.spec.tsx b/tests/index.spec.tsx index ff5f759..aa145c0 100644 --- a/tests/index.spec.tsx +++ b/tests/index.spec.tsx @@ -3,7 +3,12 @@ import { fireEvent, render } from '@testing-library/react'; import KeyCode from 'rc-util/lib/KeyCode'; import React, { Fragment } from 'react'; import Collapse, { Panel } from '../src/index'; -import type { CollapseProps, ItemType } from '../src/interface'; +import type { + CollapseProps, + HeadingLevelType, + ItemType, + PanelContentRoleType, +} from '../src/interface'; describe('collapse', () => { let changeHook: jest.Mock | null; @@ -79,11 +84,11 @@ describe('collapse', () => { }); it('click should toggle panel state', () => { - const header = collapse.container.querySelectorAll('.rc-collapse-header')?.[1]; - fireEvent.click(header); + const getHeader = () => collapse.container.querySelectorAll('.rc-collapse-header')?.[1]; + fireEvent.click(getHeader()); jest.runAllTimers(); expect(collapse.container.querySelectorAll('.rc-collapse-panel-active')).toHaveLength(1); - fireEvent.click(header); + fireEvent.click(getHeader()); jest.runAllTimers(); expect(collapse.container.querySelector('.rc-collapse-panel-inactive')?.innerHTML).toBe( '
second
', @@ -205,6 +210,148 @@ describe('collapse', () => { }); }); + describe('prop: id', () => { + const runIdTest = (element: any) => { + const { container } = render(element); + expect(container.querySelector('#collapse-test-id')).toHaveClass('rc-collapse'); + expect(container.querySelector('#collapse-test-id__item-0')).toHaveClass('rc-collapse-item'); + + const header = container.querySelector('#collapse-test-id__item-0__header'); + expect(header).toHaveClass('rc-collapse-header'); + expect(header).toHaveAttribute('aria-controls', 'collapse-test-id__item-0__content'); + fireEvent.click(header); + jest.runAllTimers(); + const panelContent = container.querySelector('#collapse-test-id__item-0__content'); + expect(panelContent).toHaveClass('rc-collapse-panel'); + expect(panelContent).toHaveAttribute('aria-labelledby', 'collapse-test-id__item-0__header'); + }; + + it('applies the passed id to subcomponents - using composition', () => { + const element = ( + + + first + + + ); + + runIdTest(element); + }); + + it('applies the passed id to subcomponents - using items prop', () => { + const element = ( + + ); + + runIdTest(element); + }); + }); + + describe('prop: headingLevel', () => { + const runHeadingLevelTest = (element: any, headingLevel: HeadingLevelType) => { + const { container } = render(element); + const header = container.querySelector('.rc-collapse-header'); + if (headingLevel) { + expect(header.parentElement.tagName).toEqual(headingLevel.toUpperCase()); + } else { + expect(header.parentElement.tagName).toEqual('DIV'); + } + }; + + const headingElements: HeadingLevelType[] = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', undefined]; + test.each(headingElements)( + 'correctly creates element when headingLevel=%p - using composition', + (headingLevel) => { + const element = ( + + + first + + + ); + + runHeadingLevelTest(element, headingLevel); + }, + ); + + test.each(headingElements)( + 'correctly creates element when headingLevel=%p - using items prop', + (headingLevel) => { + const element = ( + + ); + + runHeadingLevelTest(element, headingLevel); + }, + ); + }); + + describe('prop: panelContentRole', () => { + const runPanelContentRoleTest = (element: any, role: PanelContentRoleType) => { + const { container } = render(element); + const header = container.querySelector('.rc-collapse-header'); + fireEvent.click(header); + const panel = container.querySelector('.rc-collapse-panel'); + if (role) { + expect(panel.getAttribute('role')).toEqual(role); + } else { + expect(panel.getAttribute('role')).toEqual(null); + } + }; + + const panelRoles: PanelContentRoleType[] = ['region', 'none', undefined]; + test.each(panelRoles)( + 'correctly applies role when panelContentRole=%p - using composition', + (role) => { + const element = ( + + + first + + + ); + runPanelContentRoleTest(element, role); + }, + ); + + test.each(panelRoles)( + 'correctly applies role when panelContentRole=%p - using items prop', + (role) => { + const element = ( + + ); + runPanelContentRoleTest(element, role); + }, + ); + }); + it('should support extra whit number 0', () => { const { container } = render( From 9f789a2cd37baa7f81163af5974ee233b7b9b715 Mon Sep 17 00:00:00 2001 From: Jon Cullison Date: Tue, 8 Apr 2025 14:52:17 -0400 Subject: [PATCH 2/7] adds default id --- package.json | 3 +- src/Collapse.tsx | 7 +++- tests/__snapshots__/index.spec.tsx.snap | 13 ++++++ tests/index.spec.tsx | 54 +++++++++++++++++++++---- 4 files changed, 66 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index b349b40..9df3ef7 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,8 @@ "@babel/runtime": "^7.10.1", "@rc-component/util": "^1.0.1", "classnames": "2.x", - "rc-motion": "^2.3.4" + "rc-motion": "^2.3.4", + "uuid": "^11.1.0" }, "devDependencies": { "@rc-component/father-plugin": "^2.0.1", diff --git a/src/Collapse.tsx b/src/Collapse.tsx index 95a2623..65c7ac5 100644 --- a/src/Collapse.tsx +++ b/src/Collapse.tsx @@ -6,6 +6,7 @@ import useItems from './hooks/useItems'; import type { CollapseProps } from './interface'; import CollapsePanel from './Panel'; import pickAttrs from '@rc-component/util/lib/pickAttrs'; +import { v4 as uuid4 } from 'uuid'; function getActiveKeysArray(activeKey: React.Key | React.Key[]) { let currentActiveKey = activeKey; @@ -39,6 +40,8 @@ const Collapse = React.forwardRef((props, ref) => id, } = props; + const [collapseId] = React.useState(id ?? uuid4()); + const collapseClassName = classNames(prefixCls, className); const [activeKey, setActiveKey] = useMergedState([], { @@ -82,7 +85,7 @@ const Collapse = React.forwardRef((props, ref) => styles, contentRole: panelContentRole, headingLevel, - id: id, + id: collapseId, }); // ======================== Render ======================== @@ -93,7 +96,7 @@ const Collapse = React.forwardRef((props, ref) => style={style} role={accordion ? 'tablist' : undefined} {...pickAttrs(props, { aria: true, data: true })} - id={id} + id={collapseId} > {mergedChildren}
diff --git a/tests/__snapshots__/index.spec.tsx.snap b/tests/__snapshots__/index.spec.tsx.snap index 8e1a456..fb6101e 100644 --- a/tests/__snapshots__/index.spec.tsx.snap +++ b/tests/__snapshots__/index.spec.tsx.snap @@ -3,14 +3,18 @@ exports[`collapse props items should work with nested 1`] = `