Skip to content

feat: accessibility improvements #363

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions assets/index.less
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
border-top: none;
}

> .@{prefixCls}-header {
> .@{prefixCls}-header,
> .@{prefixCls}-header-wrapper > .@{prefixCls}-header {
display: flex;
align-items: center;
line-height: 22px;
Expand Down Expand Up @@ -76,10 +77,14 @@
}
}

& > &-item-disabled > .@{prefixCls}-header {
cursor: not-allowed;
color: #999;
background-color: #f3f3f3;

& > &-item-disabled {
> .@{prefixCls}-header,
> .@{prefixCls}-header-wrapper > .@{prefixCls}-header {
cursor: not-allowed;
color: #999;
background-color: #f3f3f3;
}
}

&-panel {
Expand All @@ -105,7 +110,8 @@
}

& > &-item-active {
> .@{prefixCls}-header {
> .@{prefixCls}-header,
> .@{prefixCls}-header-wrapper > .@{prefixCls}-header {
.arrow {
position: relative;
top: 2px;
Expand Down
7 changes: 7 additions & 0 deletions src/Collapse.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 useId from '@rc-component/util/lib/hooks/useId';

function getActiveKeysArray(activeKey: React.Key | React.Key[]) {
let currentActiveKey = activeKey;
Expand Down Expand Up @@ -34,8 +35,11 @@ const Collapse = React.forwardRef<HTMLDivElement, CollapseProps>((props, ref) =>
items,
classNames: customizeClassNames,
styles,
headingLevel,
id,
} = props;

const collapseId = useId(id);
const collapseClassName = classNames(prefixCls, className);

const [activeKey, setActiveKey] = useMergedState<React.Key | React.Key[], React.Key[]>([], {
Expand Down Expand Up @@ -77,6 +81,8 @@ const Collapse = React.forwardRef<HTMLDivElement, CollapseProps>((props, ref) =>
activeKey,
classNames: customizeClassNames,
styles,
headingLevel,
parentId: collapseId,
});

// ======================== Render ========================
Expand All @@ -87,6 +93,7 @@ const Collapse = React.forwardRef<HTMLDivElement, CollapseProps>((props, ref) =>
style={style}
role={accordion ? 'tablist' : undefined}
{...pickAttrs(props, { aria: true, data: true })}
id={collapseId}
>
{mergedChildren}
</div>
Expand Down
35 changes: 25 additions & 10 deletions src/Panel.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -25,6 +26,8 @@ const CollapsePanel = React.forwardRef<HTMLDivElement, CollapsePanelProps>((prop
openMotion,
destroyInactivePanel,
children,
headingLevel,
id,
...resetProps
} = props;

Expand Down Expand Up @@ -87,17 +90,27 @@ const CollapsePanel = React.forwardRef<HTMLDivElement, CollapsePanelProps>((prop

// ======================== Render ========================
return (
<div {...resetProps} ref={ref} className={collapsePanelClassNames}>
<div {...headerProps}>
{showArrow && iconNode}
<span
className={classNames(`${prefixCls}-title`, customizeClassNames?.title)}
style={styles?.title}
{...(collapsible === 'header' ? collapsibleProps : {})}
<div {...resetProps} ref={ref} className={collapsePanelClassNames} id={id}>
<div
className={`${prefixCls}-header-wrapper`}
role={headingLevel ? 'heading' : undefined}
aria-level={headingLevel}
>
<div
{...headerProps}
id={id ? `${id}__header` : undefined}
aria-controls={id ? `${id}__content` : undefined}
>
{header}
</span>
{ifExtraExist && <div className={`${prefixCls}-extra`}>{extra}</div>}
{showArrow && iconNode}
<span
className={classNames(`${prefixCls}-title`, customizeClassNames?.title)}
Copy link
Member

@zombieJ zombieJ Apr 23, 2025

Choose a reason for hiding this comment

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

replace this with HeadingElement instead of span directly. It's no need to have additional HeaderWrapper.

Copy link
Author

@jon-cullison jon-cullison Apr 29, 2025

Choose a reason for hiding this comment

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

I can change HeaderWrapper, but replacing the span would not satisfy the a11y standards for collapse / accordion components. The heading element should wrap the header button, which is where HeaderWrapper is in this PR. Would you prefer it to be a div that is always present around the button, instead of conditionally rendered, but it only would have the heading role and aria-level when the prop is defined?

Copy link
Member

Choose a reason for hiding this comment

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

OK. So you can add on the rc-collapse-header directly.

Copy link
Author

@jon-cullison jon-cullison May 21, 2025

Choose a reason for hiding this comment

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

Update - the rc-collapse-header element gets the button role, so to satisfy the a11y standards for the header, I changed to a div element that always wraps rc-collapse-header and will conditionally have the heading role and aria-level props set.

style={styles?.title}
{...(collapsible === 'header' ? collapsibleProps : {})}
>
{header}
</span>
{ifExtraExist && <div className={`${prefixCls}-extra`}>{extra}</div>}
</div>
</div>
<CSSMotion
visible={isActive}
Expand All @@ -110,6 +123,8 @@ const CollapsePanel = React.forwardRef<HTMLDivElement, CollapsePanelProps>((prop
return (
<PanelContent
ref={motionRef}
id={id ? `${id}__content` : undefined}
aria-labelledby={id ? `${id}__header` : undefined}
prefixCls={prefixCls}
className={motionClassName}
classNames={customizeClassNames}
Expand Down
3 changes: 3 additions & 0 deletions src/PanelContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const PanelContent = React.forwardRef<
role,
classNames: customizeClassNames,
styles,
id,
} = props;

const [rendered, setRendered] = React.useState(isActive || forceRender);
Expand All @@ -33,6 +34,8 @@ const PanelContent = React.forwardRef<
return (
<div
ref={ref}
id={id}
aria-labelledby={props['aria-labelledby']}
className={classnames(
`${prefixCls}-panel`,
{
Expand Down
17 changes: 16 additions & 1 deletion src/hooks/useItems.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,17 @@ import CollapsePanel from '../Panel';

type Props = Pick<
CollapsePanelProps,
'prefixCls' | 'onItemClick' | 'openMotion' | 'expandIcon' | 'classNames' | 'styles'
| 'prefixCls'
| 'onItemClick'
| 'openMotion'
| 'expandIcon'
| 'classNames'
| 'styles'
| 'headingLevel'
> &
Pick<CollapseProps, 'accordion' | 'collapsible' | 'destroyInactivePanel'> & {
activeKey: React.Key[];
parentId?: string;
};

const convertItemsToNodes = (items: ItemType[], props: Props) => {
Expand All @@ -23,6 +30,8 @@ const convertItemsToNodes = (items: ItemType[], props: Props) => {
expandIcon,
classNames: collapseClassNames,
styles,
headingLevel,
parentId,
} = props;

return items.map((item, index) => {
Expand Down Expand Up @@ -71,6 +80,8 @@ const convertItemsToNodes = (items: ItemType[], props: Props) => {
collapsible={mergeCollapsible}
onItemClick={handleItemClick}
destroyInactivePanel={mergeDestroyInactivePanel}
headingLevel={headingLevel}
id={parentId ? `${parentId}__item-${key}` : undefined}
>
{children}
</CollapsePanel>
Expand Down Expand Up @@ -99,6 +110,8 @@ const getNewChild = (
expandIcon,
classNames: collapseClassNames,
styles,
headingLevel,
parentId,
} = props;

const key = child.key || String(index);
Expand Down Expand Up @@ -142,6 +155,8 @@ const getNewChild = (
onItemClick: handleItemClick,
expandIcon,
collapsible: mergeCollapsible,
headingLevel,
id: parentId ? `${parentId}__item-${key}` : undefined,
};

// https://github.com/ant-design/ant-design/issues/20479
Expand Down
4 changes: 4 additions & 0 deletions src/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { CSSMotionProps } from 'rc-motion';
import type * as React from 'react';

export type CollapsibleType = 'header' | 'icon' | 'disabled';
export type HeadingLevelType = 1 | 2 | 3 | 4 | 5 | 6;

export interface ItemType
extends Omit<
Expand Down Expand Up @@ -39,6 +40,8 @@ export interface CollapseProps {
items?: ItemType[];
classNames?: Partial<Record<SemanticName, string>>;
styles?: Partial<Record<SemanticName, React.CSSProperties>>;
headingLevel?: HeadingLevelType;
id?: string;
}

export type SemanticName = 'header' | 'title' | 'body' | 'icon';
Expand All @@ -64,4 +67,5 @@ export interface CollapsePanelProps extends React.DOMAttributes<HTMLDivElement>
role?: string;
collapsible?: CollapsibleType;
children?: React.ReactNode;
headingLevel?: HeadingLevelType;
}
Loading