Skip to content
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

feat: Allow Cards selection by clicking anywhere on the card with entireCardClickable property #1670

Merged
merged 5 commits into from
Nov 2, 2023
Merged
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
66 changes: 57 additions & 9 deletions pages/cards/selection.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,28 @@
// SPDX-License-Identifier: Apache-2.0
import React, { useState } from 'react';
import range from 'lodash/range';
import Cards, { CardsProps } from '~components/cards/index';
import Header from '~components/header/index';
import Cards, { CardsProps } from '~components/cards';
import Header from '~components/header';
import Toggle from '~components/toggle';
import { EmptyState, getMatchesCountText, paginationLabels } from '../table/shared-configs';
import { useCollection } from '@cloudscape-design/collection-hooks';
import Pagination from '~components/pagination';
import TextFilter from '~components/text-filter';

interface Item {
number: number;
text: string;
}

const renderAriaLive: CardsProps['renderAriaLive'] = ({ firstIndex, lastIndex, totalItemsCount }) =>
`Displaying items ${firstIndex} to ${lastIndex} of ${totalItemsCount}`;

const getHeaderCounterText = (items: ReadonlyArray<unknown>, selectedItems: ReadonlyArray<unknown> | undefined) => {
return selectedItems && selectedItems?.length > 0 ? `(${selectedItems.length}/${items.length})` : `(${items.length})`;
};

const ariaLabels: CardsProps<Item>['ariaLabels'] = {
selectionGroupLabel: 'group label',
selectionGroupLabel: 'Resource selection',
itemSelectionLabel: ({ selectedItems }, item) =>
`${item.text} is ${!selectedItems.includes(item) ? 'not ' : ''}selected`,
};
Expand All @@ -37,21 +49,57 @@ const cardDefinition: CardsProps.CardDefinition<Item> = {
],
};

const items = createSimpleItems(4);
const allItems = createSimpleItems(50);

export default function () {
const [selectedItems, setSelectedItems] = useState<Item[]>([]);
const [selectionType, setSelectionType] = useState<CardsProps.SelectionType>('multi');
const [entireCard, setEntireCard] = useState(false);
const [someDisabled, setSomeDisabled] = useState(false);

const { items, filteredItemsCount, collectionProps, filterProps, paginationProps } = useCollection(allItems, {
filtering: {
empty: <EmptyState title="No resources" subtitle="No resources to display." action={null} />,
noMatch: <EmptyState title="No matches" subtitle="We can’t find a match." action={null} />,
},
pagination: { pageSize: 10 },
selection: {},
});

return (
<>
<h1>Cards selection</h1>
<Toggle checked={entireCard} onChange={event => setEntireCard(event.detail.checked)}>
Allow clicking entire card to select
</Toggle>
<Toggle checked={someDisabled} onChange={event => setSomeDisabled(event.detail.checked)}>
Make some card elements inactive
</Toggle>
<Toggle
checked={selectionType === 'multi'}
onChange={event => setSelectionType(event.detail.checked ? 'multi' : 'single')}
>
Use multi selection
</Toggle>
<Cards<Item>
{...collectionProps}
items={items}
cardDefinition={cardDefinition}
header={<Header>Cards header</Header>}
selectionType="multi"
selectedItems={selectedItems}
onSelectionChange={({ detail }) => setSelectedItems(detail.selectedItems)}
header={<Header counter={getHeaderCounterText(allItems, collectionProps.selectedItems)}>Resources</Header>}
selectionType={selectionType}
ariaLabels={ariaLabels}
renderAriaLive={renderAriaLive}
entireCardClickable={entireCard}
isItemDisabled={item => someDisabled && !item.text.includes('o')}
pagination={<Pagination {...paginationProps} ariaLabels={paginationLabels} />}
filter={
<TextFilter
{...filterProps}
filteringAriaLabel="Filter resources"
filteringPlaceholder="Find resources"
filteringClearAriaLabel="Clear"
countText={getMatchesCountText(filteredItemsCount ?? 0)}
/>
}
/>
</>
);
Expand Down
7 changes: 7 additions & 0 deletions src/__tests__/__snapshots__/documenter.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -3827,6 +3827,13 @@ Default value:
"optional": true,
"type": "string",
},
Object {
"description": "Activating this property makes the entire card clickable to select it.
Don't use this property if the card has any other interactive elements.",
"name": "entireCardClickable",
"optional": true,
"type": "boolean",
},
Object {
"description": " Use this property to inform screen readers which range of cards is currently displayed.
It specifies the index (1-based) of the first card.
Expand Down
15 changes: 15 additions & 0 deletions src/cards/__tests__/selection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,21 @@ describe('Cards selection', () => {
).wrapper;
expect(getSelectedCardsText()).toEqual(['0']);
});

it('cannot select items by clicking anywhere on the card by default', () => {
wrapper = renderCards(<Cards<Item> {...props} selectionType={selectionType} />).wrapper;
getCard(1).findCardHeader()?.click();
expect(handleSelectionChange).not.toHaveBeenCalled();
});

it('can select items by clicking anywhere on the card when entireCardClickable is enabled', () => {
wrapper = renderCards(
<Cards<Item> {...props} selectionType={selectionType} entireCardClickable={true} />
).wrapper;
getCard(1).findCardHeader()?.click();
expectSelected([{ description: '1' }]);
expect(getCardSelectionArea(1)?.find('input')?.getElement()).toHaveFocus();
});
});

describe('Single selection', () => {
Expand Down
22 changes: 20 additions & 2 deletions src/cards/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ const Cards = React.forwardRef(function <T = any>(
renderAriaLive,
firstIndex,
totalItemsCount,
entireCardClickable,
...rest
}: CardsProps<T>,
ref: React.Ref<CardsProps.Ref>
Expand Down Expand Up @@ -187,6 +188,7 @@ const Cards = React.forwardRef(function <T = any>(
onFocus={onCardFocus}
ariaLabel={ariaLabels?.cardsLabel}
ariaLabelledby={isLabelledByHeader && headerIdRef.current ? headerIdRef.current : undefined}
entireCardClickable={entireCardClickable}
/>
)}
</div>
Expand All @@ -212,7 +214,11 @@ const CardsList = <T,>({
onFocus,
ariaLabelledby,
ariaLabel,
}: Pick<CardsProps<T>, 'items' | 'cardDefinition' | 'trackBy' | 'selectionType' | 'visibleSections'> & {
entireCardClickable,
}: Pick<
CardsProps<T>,
'items' | 'cardDefinition' | 'trackBy' | 'selectionType' | 'visibleSections' | 'entireCardClickable'
> & {
columns: number | null;
isItemSelected: (item: T) => boolean;
getItemSelectionProps: (item: T) => SelectionControlProps;
Expand All @@ -223,6 +229,7 @@ const CardsList = <T,>({
ariaDescribedby?: string;
}) => {
const selectable = !!selectionType;
const canClickEntireCard = selectable && entireCardClickable;

const { moveFocusDown, moveFocusUp } = useSelectionFocusMove(selectionType, items.length);

Expand Down Expand Up @@ -260,7 +267,18 @@ const CardsList = <T,>({
{...(focusMarkers && focusMarkers.item)}
role={listItemRole}
>
<div className={styles['card-inner']}>
<div
className={styles['card-inner']}
onClick={
canClickEntireCard
? event => {
getItemSelectionProps(item).onChange();
// Manually move focus to the native input (checkbox or radio button)
event.currentTarget.querySelector('input')?.focus();
}
: undefined
}
>
<div className={styles['card-header']}>
<div className={styles['card-header-inner']}>
{cardDefinition.header ? cardDefinition.header(item) : ''}
Expand Down
6 changes: 6 additions & 0 deletions src/cards/interfaces.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,12 @@ export interface CardsProps<T = any> extends BaseComponentProps {
* @visualrefresh `full-page` variant
*/
variant?: 'container' | 'full-page';

/**
* Activating this property makes the entire card clickable to select it.
* Don't use this property if the card has any other interactive elements.
*/
entireCardClickable?: boolean;
}

export namespace CardsProps {
Expand Down
Loading