Skip to content

Commit

Permalink
Rework the swatch component
Browse files Browse the repository at this point in the history
  • Loading branch information
bendvc committed May 9, 2024
1 parent 8b88143 commit eee2aaf
Show file tree
Hide file tree
Showing 3 changed files with 122 additions and 58 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,13 @@ const ProductTile = (props) => {
?.map(({id, values}) => {
const attributeId = id
return (
<SwatchGroup key={id}>
<SwatchGroup
key={id}
value={selectableAttributeValue}
handleChange={(value) => {
setSelectableAttributeValue(value)
}}
>
{values?.map(({href, name, swatch, value}) => {
const content = swatch ? (
<Box
Expand All @@ -179,13 +185,10 @@ const ProductTile = (props) => {
<Swatch
key={value}
href={href}
handleChange={(value) => {
setSelectableAttributeValue(value)
}}
value={value}
name={name}
variant={'circle'}
selected={value === selectableAttributeValue}
isFocusable={true}
>
{content}
</Swatch>
Expand Down
121 changes: 103 additions & 18 deletions packages/template-retail-react-app/app/components/swatch-group/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* 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 React, {Children, useCallback, useEffect, useRef, useState} from 'react'
import PropTypes from 'prop-types'
import {
Flex,
Expand All @@ -14,30 +14,107 @@ import {
useStyleConfig
} from '@salesforce/retail-react-app/app/components/shared/ui'
import {FormattedMessage} from 'react-intl'
import {noop} from '@salesforce/retail-react-app/app/utils/utils'

const DIRECTIONS = {
FORWARD: 1,
BACKWARD: -1
}

/**
* 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, label = ''} = props
const {displayName, children, label = '', value, handleChange = noop} = props

const styles = useStyleConfig('SwatchGroup')
const [selectedIndex, setSelectedIndex] = useState(0)
const wrapperRef = useRef(null)

// Handle keyboard navigation.
const onKeyDown = useCallback(
(e) => {
const {key} = e
const move = (direction = DIRECTIONS.FORWARD) => {
let index = selectedIndex + direction // forward = +1 backwards = -1
index = (selectedIndex + direction) % children.length // keep number in bounds of array with modulus
index = index < 0 ? children.length - Math.abs(index) : Math.abs(index) // We we are dealing with a negative we have to invert the index

// Get a reference to the newly selected swatch as we are going to focus it later.
const swatchEl = wrapperRef?.current?.children[index]

// Set the new index that is always in the arrays range.
setSelectedIndex(index)

// Behave like a radio button a focus the new swatch.
swatchEl?.focus()
}

switch (key) {
case 'ArrowUp':
case 'ArrowLeft':
e.preventDefault()
move(DIRECTIONS.BACKWARD)
break
case 'ArrowDown':
case 'ArrowRight':
e.preventDefault()
move(DIRECTIONS.FORWARD)
break
default:
break
}
},
[selectedIndex]
)

// Initialize the component state on mount, this includes the selected index value.
useEffect(() => {
if (!value) {
return
}
const childrenArray = Children.toArray(children)
const index = childrenArray.findIndex(({props}) => props?.value === value)

setSelectedIndex(index)
}, [])

// Whenever the selected index changes ensure that we call the change handler.
useEffect(() => {
const childrenArray = Children.toArray(children)
const newValue = childrenArray[selectedIndex].props.value

handleChange(newValue)
}, [selectedIndex])

return (
<Flex {...styles.swatchGroup} role="radiogroup" aria-label={label}>
{label && (
<HStack {...styles.swatchLabel}>
<Box fontWeight="semibold">
<FormattedMessage
id="swatch_group.selected.label"
defaultMessage="{label}:"
values={{label}}
/>
</Box>
<Box>{displayName}</Box>
</HStack>
)}
<Flex {...styles.swatchesWrapper}>{children}</Flex>
</Flex>
<Box onKeyDown={onKeyDown}>
<Flex {...styles.swatchGroup} role="radiogroup" aria-label={label}>
{label && (
<HStack {...styles.swatchLabel}>
<Box fontWeight="semibold">
<FormattedMessage
id="swatch_group.selected.label"
defaultMessage="{label}:"
values={{label}}
/>
</Box>
<Box>{displayName}</Box>
</HStack>
)}
<Flex ref={wrapperRef} {...styles.swatchesWrapper}>
{Children.toArray(children).map((child) => {
const selected = child.props.value === value
return React.cloneElement(child, {
handleSelect: handleChange,
selected,
isFocusable: selected
})
})}
</Flex>
</Flex>
</Box>
)
}

Expand All @@ -55,7 +132,15 @@ SwatchGroup.propTypes = {
/**
* The Swatch options to choose between
*/
children: PropTypes.node
children: PropTypes.node,
/**
* This function is called whenever the selected swatch changes.
*/
handleChange: PropTypes.func,
/**
* The currentvalue for the option.
*/
value: PropTypes.string
}

export default SwatchGroup
Original file line number Diff line number Diff line change
Expand Up @@ -29,53 +29,30 @@ const Swatch = ({
selected,
isFocusable,
value,
handleChange,
handleSelect,
variant = 'square'
}) => {
const styles = useMultiStyleConfig('SwatchGroup', {variant, disabled, selected})
const isDesktop = useBreakpointValue({base: false, lg: true})
const [changeHandlers, setChangeHandlers] = useState({})
const [selectHandlers, setSelectHandlers] = useState({})

const onKeyDown = useCallback((evt) => {
let sibling
// This is not a very react-y way implementation... ¯\_(ツ)_/¯
switch (evt.key) {
case 'ArrowUp':
case 'ArrowLeft':
evt.preventDefault()
sibling =
evt.target.previousElementSibling || evt.target.parentElement.lastElementChild
break
case 'ArrowDown':
case 'ArrowRight':
evt.preventDefault()
sibling =
evt.target.nextElementSibling || evt.target.parentElement.firstElementChild
break
default:
break
}
sibling?.click()
sibling?.focus()
}, [])

const onChange = useCallback(
const onSelect = useCallback(
(e) => {
e.preventDefault()
handleChange(value)
handleSelect(value)
},
[handleChange]
[handleSelect]
)

useEffect(() => {
if (!handleChange) {
if (!handleSelect) {
return
}

setChangeHandlers({
[isDesktop ? 'onMouseEnter' : 'onClick']: onChange
setSelectHandlers({
[isDesktop ? 'onMouseEnter' : 'onClick']: onSelect
})
}, [onChange, isDesktop])
}, [onSelect, isDesktop])

return (
<Button
Expand All @@ -84,13 +61,12 @@ const Swatch = ({
to={href}
aria-label={name}
aria-checked={selected}
onKeyDown={onKeyDown}
variant="outline"
role="radio"
// To mimic the behavior of native radio inputs, only one input should be focusable.
// (The rest are selectable via arrow keys.)
tabIndex={isFocusable ? 0 : -1}
{...changeHandlers}
{...selectHandlers}
>
<Center {...styles.swatchButton}>
{children}
Expand Down Expand Up @@ -144,7 +120,7 @@ Swatch.propTypes = {
* This function is called whenever the mouse enters the swatch on desktop or when clicked on mobile.
* The values is passed as the first argument.
*/
handleChange: PropTypes.func
handleSelect: PropTypes.func
}

export default Swatch

0 comments on commit eee2aaf

Please sign in to comment.