Skip to content

Commit

Permalink
@W-12627096@ Improve accessibility of variation attribute swatches (#…
Browse files Browse the repository at this point in the history
…1587)

* Convert hard-coded text to translatable message.

* Remove unnecessary `onChange` handler.

The swatches are also links, which accomplishes the same thing.

* Define attributes in product view, rather than swatch group.

Using React.Children + cloneElement is not recommended because it's weird and confusing.

* Fix children prop type.

* Move variation attribute swatch groups into separate component.

* Add arrow key support to swatch radios.

* just click

* Update changelog.

---------

Signed-off-by: Will Harney <[email protected]>
  • Loading branch information
wjhsf authored Dec 18, 2023
1 parent 90a97db commit 4815a62
Show file tree
Hide file tree
Showing 10 changed files with 176 additions and 130 deletions.
1 change: 1 addition & 0 deletions packages/template-retail-react-app/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

### Accessibility improvements

- Add correct keyboard interaction behavior for variation attribute radio buttons [#1587](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/1587)
- Change radio refinements (for example, filtering by Price) from radio inputs to styled buttons [#1605](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/1605)
- Update search refinements ARIA labels to include "add/remove filter" [#1607](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/1607)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
/*
* Copyright (c) 2021, salesforce.com, inc.
* Copyright (c) 2023, Salesforce, Inc.
* All rights reserved.
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/

import React, {forwardRef, useEffect, useRef, useState} from 'react'
import PropTypes from 'prop-types'
import {useHistory, useLocation} from 'react-router-dom'
import {useLocation} from 'react-router-dom'
import {useIntl, FormattedMessage} from 'react-intl'

import {
Expand All @@ -25,8 +25,6 @@ import {useDerivedProduct} from '@salesforce/retail-react-app/app/hooks'
import {useAddToCartModalContext} from '@salesforce/retail-react-app/app/hooks/use-add-to-cart-modal'

// project components
import SwatchGroup from '@salesforce/retail-react-app/app/components/swatch-group'
import Swatch from '@salesforce/retail-react-app/app/components/swatch-group/swatch'
import ImageGallery from '@salesforce/retail-react-app/app/components/image-gallery'
import Breadcrumb from '@salesforce/retail-react-app/app/components/breadcrumb'
import Link from '@salesforce/retail-react-app/app/components/link'
Expand All @@ -37,6 +35,8 @@ import QuantityPicker from '@salesforce/retail-react-app/app/components/quantity
import {useToast} from '@salesforce/retail-react-app/app/hooks/use-toast'
import {API_ERROR_MESSAGE} from '@salesforce/retail-react-app/app/constants'
import DisplayPrice from '@salesforce/retail-react-app/app/components/display-price'
import Swatch from '@salesforce/retail-react-app/app/components/swatch-group/swatch'
import SwatchGroup from '@salesforce/retail-react-app/app/components/swatch-group'
import {getDisplayPrice} from '@salesforce/retail-react-app/app/utils/product-utils'

const ProductViewHeader = ({name, basePrice, discountPrice, currency, category, productType}) => {
Expand Down Expand Up @@ -104,7 +104,6 @@ const ProductView = forwardRef(
) => {
const showToast = useToast()
const intl = useIntl()
const history = useHistory()
const location = useLocation()
const {
isOpen: isAddToCartModalOpen,
Expand Down Expand Up @@ -357,69 +356,70 @@ const ProductView = forwardRef(
<Skeleton height={20} width={64} />
</>
) : (
<>
{/* Attribute Swatches */}
{variationAttributes.map((variationAttribute) => {
const {
id,
name,
selectedValue,
values = []
} = variationAttribute
return (
<SwatchGroup
key={id}
onChange={(_, href) => {
if (!href) return
history.replace(href)
}}
variant={id === 'color' ? 'circle' : 'square'}
value={selectedValue?.value}
displayName={selectedValue?.name || ''}
label={intl.formatMessage(
{
defaultMessage: '{variantType}',
id: 'product_view.label.variant_type'
},
{variantType: name}
)}
>
{values.map(
({href, name, image, value, orderable}) => (
<Swatch
key={value}
href={href}
disabled={!orderable}
value={value}
name={name}
>
{image ? (
<Box
height="100%"
width="100%"
minWidth="32px"
backgroundRepeat="no-repeat"
backgroundSize="cover"
backgroundColor={name.toLowerCase()}
backgroundImage={
image
? `url(${
image.disBaseLink ||
image.link
})`
: ''
}
/>
) : (
name
)}
</Swatch>
)
)}
</SwatchGroup>
)
})}
</>
variationAttributes.map(({id, name, selectedValue, values}) => {
const swatches = values.map(
({href, name, image, value, orderable}, index) => {
const content = image ? (
<Box
height="100%"
width="100%"
minWidth="32px"
backgroundRepeat="no-repeat"
backgroundSize="cover"
backgroundColor={name.toLowerCase()}
backgroundImage={`url(${
image.disBaseLink || image.link
})`}
/>
) : (
name
)
const hasSelection = Boolean(selectedValue?.value)
const isSelected = selectedValue?.value === value
const isFirst = index === 0
// To mimic the behavior of a native radio input, only
// one swatch should receive tab focus; the rest can be
// selected using arrow keys when the swatch group has
// focus. The focused element is the selected option or
// the first in the group, if no option is selected.
// This is a slight difference, for simplicity, from the
// native element, where the first element is focused on
// `Tab` and the _last_ element is focused on `Shift+Tab`
const isFocusable =
isSelected || (!hasSelection && isFirst)
return (
<Swatch
key={value}
href={href}
disabled={!orderable}
value={value}
name={name}
variant={id === 'color' ? 'circle' : 'square'}
selected={isSelected}
isFocusable={isFocusable}
>
{content}
</Swatch>
)
}
)
return (
<SwatchGroup
key={id}
value={selectedValue?.value}
displayName={selectedValue?.name || ''}
label={intl.formatMessage(
{
defaultMessage: '{variantType}',
id: 'product_view.label.variant_type'
},
{variantType: name}
)}
>
{swatches}
</SwatchGroup>
)
})
)}

{/* Quantity Selector */}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,39 +13,28 @@ import {
HStack,
useStyleConfig
} from '@salesforce/retail-react-app/app/components/shared/ui'
import {noop} from '@salesforce/retail-react-app/app/utils/utils'
import {FormattedMessage} from 'react-intl'

/**
* SwatchGroup allows you to create a list of swatches
* Each Swatch is a link with will direct to a href passed to them
*/
const SwatchGroup = (props) => {
const {
displayName,
children,
value: selectedValue,
label = '',
variant = 'square',
onChange = noop
} = props
const {displayName, children, label = ''} = props
const styles = useStyleConfig('SwatchGroup')
return (
<Flex {...styles.swatchGroup} role="radiogroup" aria-label={label}>
<HStack {...styles.swatchLabel}>
<Box fontWeight="semibold">{`${label}:`}</Box>
<Box fontWeight="semibold">
<FormattedMessage
id="swatch_group.selected.label"
defaultMessage="{label}:"
values={{label}}
/>
</Box>
<Box>{displayName}</Box>
</HStack>
<Flex {...styles.swatchesWrapper}>
{React.Children.map(children, (child) => {
const childValue = child.props.value

return React.cloneElement(child, {
selected: childValue === selectedValue,
variant,
onChange
})
})}
</Flex>
<Flex {...styles.swatchesWrapper}>{children}</Flex>
</Flex>
)
}
Expand All @@ -57,26 +46,14 @@ SwatchGroup.propTypes = {
* The attribute name of the swatch group. E.g color, size
*/
label: PropTypes.string,
/**
* The selected Swatch value.
*/
value: PropTypes.string,
/**
* The display value of the selected option
*/
displayName: PropTypes.string,
/**
* The Swatch options to choose between
*/
children: PropTypes.array,
/**
* The shape of the swatches
*/
variant: PropTypes.oneOf(['square', 'circle']),
/**
* This function is called when a new option is selected
*/
onChange: PropTypes.func
children: PropTypes.node
}

export default SwatchGroup
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import React from 'react'
import {screen, render, fireEvent, waitFor} from '@testing-library/react'
import {createMemoryHistory} from 'history'
import {Router, useHistory, useLocation} from 'react-router-dom'
import SwatchGroup from '@salesforce/retail-react-app/app/components/swatch-group/index'
import {screen, fireEvent, waitFor} from '@testing-library/react'

import {Box} from '@salesforce/retail-react-app/app/components/shared/ui'
import SwatchGroup from '@salesforce/retail-react-app/app/components/swatch-group/index'
import Swatch from '@salesforce/retail-react-app/app/components/swatch-group/swatch'
import {createMemoryHistory} from 'history'
import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils'

const data = {
id: 'color',
Expand Down Expand Up @@ -108,7 +110,7 @@ describe('Swatch Component', () => {
const history = createMemoryHistory()
history.push('/en-GB/swatch-example?color=JJ2XNXX')

render(
renderWithProviders(
<Router history={history}>
<Page />
</Router>
Expand All @@ -120,7 +122,7 @@ describe('Swatch Component', () => {
const history = createMemoryHistory()
history.push('/en-GB/swatch-example')

render(
renderWithProviders(
<Router history={history}>
<Page />
</Router>
Expand Down
Loading

0 comments on commit 4815a62

Please sign in to comment.