Skip to content

Commit

Permalink
feat: implement accessibility features for selectable cards
Browse files Browse the repository at this point in the history
  • Loading branch information
marcosmoura committed Nov 29, 2022
1 parent dfe4306 commit 86dad78
Show file tree
Hide file tree
Showing 18 changed files with 191 additions and 278 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"type": "prerelease",
"packageName": "@fluentui/react-card",
"email": "[email protected]",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "prerelease",
"comment": "fix selectable card accessibility",
"packageName": "@fluentui/react-card",
"email": "[email protected]",
"dependentChangeType": "patch"
}
14 changes: 8 additions & 6 deletions packages/react-components/react-card/etc/react-card.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ export type CardHeaderSlots = {
// @public
export type CardHeaderState = ComponentState<CardHeaderSlots>;

// @public
export type CardOnSelectionChangeEvent = React_2.MouseEvent | React_2.KeyboardEvent | React_2.ChangeEvent;

// @public
export const CardPreview: ForwardRefComponent<CardPreviewProps>;

Expand All @@ -93,12 +96,13 @@ export type CardProps = ComponentProps<CardSlots> & {
size?: 'small' | 'medium' | 'large';
selected?: boolean;
defaultSelected?: boolean;
onSelectionChange?: (event: CarOnSelectionChangeEvent, data: CardOnSelectData) => void;
onSelectionChange?: (event: CardOnSelectionChangeEvent, data: CardOnSelectData) => void;
selectableProps?: React_2.InputHTMLAttributes<HTMLInputElement>;
};

// @public
export type CardSlots = {
root: Slot<'div', 'a' | 'button'>;
root: Slot<'div'>;
select?: Slot<'div', 'input'>;
};

Expand All @@ -108,11 +112,9 @@ export type CardState = ComponentState<CardSlots> & Required<Pick<CardProps, 'ap
selectable: boolean;
hasSelectSlot: boolean;
selected: boolean;
selectFocused: boolean;
}>;

// @public
export type CarOnSelectionChangeEvent = React_2.MouseEvent | React_2.KeyboardEvent | React_2.ChangeEvent;

// @public
export const renderCard_unstable: (state: CardState) => JSX.Element;

Expand All @@ -126,7 +128,7 @@ export const renderCardHeader_unstable: (state: CardHeaderState) => JSX.Element;
export const renderCardPreview_unstable: (state: CardPreviewState) => JSX.Element;

// @public
export const useCard_unstable: (props: CardProps, ref: React_2.Ref<CardRefElement>) => CardState;
export const useCard_unstable: (props: CardProps, ref: React_2.Ref<HTMLDivElement>) => CardState;

// @public
export const useCardFooter_unstable: (props: CardFooterProps, ref: React_2.Ref<HTMLElement>) => CardFooterState;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,19 +268,19 @@ describe('Card', () => {
cy.get(`.${cardClassNames.select}`).should('not.exist');
});

it('should be checked when prop is present - selected prop', () => {
it('should render select slot - selected prop', () => {
mountFluent(<CardSample selected />);

cy.get('#card').should('have.attr', 'aria-checked', 'true');
cy.get(`.${cardClassNames.select}`).should('exist');
});

it('should be checked when prop is present - defaultSelected prop', () => {
it('should render select slot - defaultSelected prop', () => {
mountFluent(<CardSample defaultSelected />);

cy.get('#card').should('have.attr', 'aria-checked', 'true');
cy.get(`.${cardClassNames.select}`).should('exist');
});

it('should not be checked when prop is present - onSelectionChange prop', () => {
it('should render select slot - onSelectionChange prop', () => {
const Example = () => {
const onSelectionChange = React.useCallback(() => null, []);

Expand All @@ -289,15 +289,21 @@ describe('Card', () => {

mountFluent(<Example />);

cy.get('#card').should('have.attr', 'aria-checked', 'false');
cy.get(`.${cardClassNames.select}`).should('exist');
});

it('should have internal checkbox when selectable - select prop', () => {
it('should render select slot custom JSX is provided', () => {
mountFluent(<CardSample select={<span />} />);

cy.get(`.${cardClassNames.select}`).should('exist');
});

it('should have internal checkbox when selectable - no select slot', () => {
mountFluent(<CardSample selected />);

cy.get(`.${cardClassNames.select}`).should('exist');
});

it('should render custom select slot', () => {
mountFluent(<CardSample select={<input type="checkbox" />} />);

Expand All @@ -307,46 +313,38 @@ describe('Card', () => {
it('should select with a mouse click', () => {
mountFluent(<CardSample defaultSelected={false} />);

cy.get(`.${cardClassNames.root}`).should('have.attr', 'aria-checked', 'false');
cy.get(`.${cardClassNames.select}`).should('not.be.checked');
cy.get(`.${cardClassNames.root}`).realClick();
cy.get(`.${cardClassNames.root}`).should('have.attr', 'aria-checked', 'true');
cy.get(`.${cardClassNames.select}`).should('be.checked');
});

it('should have checkbox pre-selected and toggle its value', () => {
mountFluent(<CardSample defaultSelected />);

cy.get(`.${cardClassNames.root}`).should('have.attr', 'aria-checked', 'true');
cy.get(`.${cardClassNames.select}`).should('be.checked');
cy.get(`.${cardClassNames.root}`).realClick();
cy.get(`.${cardClassNames.root}`).should('have.attr', 'aria-checked', 'false');
cy.get(`.${cardClassNames.select}`).should('not.be.checked');
});

it('should select with the Space key', () => {
mountFluent(<CardSample defaultSelected={false} />);

cy.get(`.${cardClassNames.root}`).focus().realPress('Space');
cy.get(`.${cardClassNames.root}`).should('have.attr', 'aria-checked', 'true');
});

it('should NOT select with the Enter key if card has any actions inside', () => {
mountFluent(<CardSample defaultSelected={false} />);

cy.get(`.${cardClassNames.root}`).focus().realPress('Enter');
cy.get(`.${cardClassNames.root}`).should('have.attr', 'aria-checked', 'false');
cy.get(`.${cardClassNames.root} button`).first().should('be.focused');
cy.get(`.${cardClassNames.select}`).focus().realPress('Space');
cy.get(`.${cardClassNames.select}`).should('be.checked');
});

it('should NOT select when focused on an action', () => {
mountFluent(<CardSample defaultSelected={false} />);

cy.get(`.${cardClassNames.root} button`).first().focus().realPress('Enter');
cy.get(`.${cardClassNames.root}`).should('have.attr', 'aria-checked', 'false');
cy.get(`.${cardClassNames.select}`).should('not.be.checked');
});

it('should select with the Enter key if card does not have any actions inside', () => {
mountFluent(<CardSampleNoActions defaultSelected={false} />);

cy.get(`.${cardClassNames.root}`).focus().realPress('Enter');
cy.get(`.${cardClassNames.root}`).should('have.attr', 'aria-checked', 'true');
cy.get(`.${cardClassNames.select}`).focus().realPress('Enter');
cy.get(`.${cardClassNames.select}`).should('be.checked');
});

it('should sync selected value with custom slot', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ import * as React from 'react';
import { useCard_unstable } from './useCard';
import { renderCard_unstable } from './renderCard';
import { useCardStyles_unstable } from './useCardStyles';
import type { CardProps, CardRefElement } from './Card.types';
import type { CardProps } from './Card.types';
import type { ForwardRefComponent } from '@fluentui/react-utilities';

/**
* A card provides scaffolding for hosting actions and content for a single topic.
*/
export const Card: ForwardRefComponent<CardProps> = React.forwardRef<CardRefElement>((props, ref) => {
export const Card: ForwardRefComponent<CardProps> = React.forwardRef<HTMLDivElement>((props, ref) => {
const state = useCard_unstable(props, ref);

useCardStyles_unstable(state);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
import * as React from 'react';
import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities';

/**
* Card refs to the root element slot.
*/
export type CardRefElement = HTMLDivElement | HTMLButtonElement | HTMLAnchorElement;

/**
* Card selected event type
*
* This event is fired when a selectable card changes its selection state.
*/
export type CarOnSelectionChangeEvent = React.MouseEvent | React.KeyboardEvent | React.ChangeEvent;
export type CardOnSelectionChangeEvent = React.MouseEvent | React.KeyboardEvent | React.ChangeEvent;

/**
* Data sent from the selection events on a selectable card.
Expand All @@ -27,7 +22,7 @@ export type CardSlots = {
/**
* Root element of the component.
*/
root: Slot<'div', 'a' | 'button'>;
root: Slot<'div'>;

/**
* Select element represents a checkbox.
Expand Down Expand Up @@ -111,7 +106,12 @@ export type CardProps = ComponentProps<CardSlots> & {
/**
* Callback to be called when the selected state value changes.
*/
onSelectionChange?: (event: CarOnSelectionChangeEvent, data: CardOnSelectData) => void;
onSelectionChange?: (event: CardOnSelectionChangeEvent, data: CardOnSelectData) => void;

/**
* Properties passed to the internal checkbox element.
*/
selectableProps?: React.InputHTMLAttributes<HTMLInputElement>;
};

/**
Expand Down Expand Up @@ -147,5 +147,12 @@ export type CardState = ComponentState<CardSlots> &
* @default false
*/
selected: boolean;

/**
* Defines whether the card internal checkbox is currently focused.
*
* @default false
*/
selectFocused: boolean;
}
>;
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ export const renderCard_unstable = (state: CardState) => {

return (
<slots.root {...slotProps.root}>
{slots.select && state.selectable ? <slots.select {...slotProps.select} /> : null}
{slotProps.root.children}
{state.hasSelectSlot && slots.select ? <slots.select {...slotProps.select} /> : null}
</slots.root>
);
};
37 changes: 23 additions & 14 deletions packages/react-components/react-card/src/components/Card/useCard.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import * as React from 'react';
import { getNativeElementProps } from '@fluentui/react-utilities';
import { useFocusableGroup } from '@fluentui/react-tabster';
import { getNativeElementProps, useMergedRefs } from '@fluentui/react-utilities';
import { useFocusableGroup, useFocusWithin } from '@fluentui/react-tabster';

import type { CardProps, CardRefElement, CardState } from './Card.types';
import type { CardProps, CardState } from './Card.types';
import { useCardSelectable } from './useCardSelectable';

const focusMap = {
Expand Down Expand Up @@ -42,16 +42,24 @@ const useCardFocusAttributes = ({ focusMode = 'off' }: CardProps, { interactive
* @param props - props from this instance of Card
* @param ref - reference to the root element of Card
*/
export const useCard_unstable = (props: CardProps, ref: React.Ref<CardRefElement>): CardState => {
const { appearance = 'filled', orientation = 'vertical', size = 'medium', as = 'div' } = props;
const cardRef = React.useRef<CardRefElement>(null);
export const useCard_unstable = (props: CardProps, ref: React.Ref<HTMLDivElement>): CardState => {
const { appearance = 'filled', orientation = 'vertical', size = 'medium' } = props;

const { selectable, hasSelectSlot, selected, selectableSlot, selectableProps } = useCardSelectable(props, cardRef);
const cardBaseRef = useFocusWithin<HTMLDivElement>();
const {
selectable,
hasSelectSlot,
selected,
selectableSlot,
selectableProps,
selectableTag,
selectFocused,
} = useCardSelectable(props, cardBaseRef);

const cardRef = useMergedRefs(cardBaseRef, ref);

const interactive = Boolean(
selectable ||
['a', 'button'].includes(as) ||
props.onClick ||
props.onClick ||
props.onDoubleClick ||
props.onMouseUp ||
props.onMouseDown ||
Expand All @@ -70,15 +78,16 @@ export const useCard_unstable = (props: CardProps, ref: React.Ref<CardRefElement
interactive,
selectable,
hasSelectSlot,
selectFocused,
selected,

components: {
root: as,
select: 'div',
root: 'div',
select: selectableTag,
},

root: getNativeElementProps(as, {
ref: ref || cardRef,
root: getNativeElementProps('div', {
ref: cardRef,
role: 'group',
...focusAttributes,
...props,
Expand Down
Loading

0 comments on commit 86dad78

Please sign in to comment.