Skip to content

Instantly share code, notes, and snippets.

@kfranqueiro
Created June 13, 2021 20:37
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save kfranqueiro/ef43561453440ae29a42fe22a1ed4a8a to your computer and use it in GitHub Desktop.
Save kfranqueiro/ef43561453440ae29a42fe22a1ed4a8a to your computer and use it in GitHub Desktop.
Implementation of https://inclusive-components.design/cards/ (using Chakra UI, but shouldn't be hard to migrate to something else) - demo at https://codesandbox.io/s/inclusive-components-cards-using-react-chakra-ui-vq9mm
import * as React from "react";
import { BoxProps, CSSObject } from "@chakra-ui/react";
interface Options {
/** Distance (in px) under which to still consider interaction a click */
threshold?: number;
}
/**
* Returns mousedown and mouseup handlers to process clicks on an ancestor of the
* intended click target (with the intent of avoiding triggering on text selection).
* Useful for interactive cards with either one or no visible child CTA.
*
* Intended to be used together with generateContainerBoxProps spread on the same ancestor element,
* and generateTargetStyles spread on a child CTA or click target.
*
* You may be wondering, "why not just make the top-level container a link or button?"
* There are several reasons, including:
* * Assistive technology will try to announce the entire card’s contents before indicating it’s a button or link
* * In the case of buttons, you lose any semantic meaning of child elements, and are restricted to only phrasing elements
*
* Based on guidance seen here: https://inclusive-components.design/cards/
*
* Example usage:
*
* ```tsx
* const [target, setTarget] = React.useState(null);
* const targetRef = React.useCallback(node => setTarget(node), [setTarget]);
* <Box
* {...{
* ...useAccessibleClickHandlers(target),
* ...generateContainerBoxProps({ boxShadow: 'card-focus' }, { boxShadow: 'card-hover' }),
* }}
* >
* This is a card with a single CTA.
* <Button
* ref={targetRef}
* onClick={handleClick}
* {...generateTargetStyles()}
* >
* Perform the action associated with this card
* </Button>
* </Box>
* ```
*
* You can also use this pattern with a visually-hidden CTA by wrapping the child
* button or link with Chakra UI's `VisuallyHidden` component.
*
* @param target Element to forward click to
* @param options
*/
export function useAccessibleClickHandlers(target: HTMLElement | null, options?: Options) {
const pos = React.useRef<{ x: number; y: number }>({
x: Infinity,
y: Infinity,
});
const threshold = options?.threshold || 8;
const onMouseDown = React.useCallback(
(event: React.MouseEvent<HTMLElement>) => {
// Set up to potentially fire event only for primary button click on element outside the actual trigger element
if (event.button === 0 && !target?.contains(event.target as Node)) {
pos.current = { x: event.clientX, y: event.clientY };
}
},
[target]
);
const onMouseUp = React.useCallback(
(event: React.MouseEvent) => {
// Check based on distance rather than time, since someone could
// hold down the mouse button for a while before being sure they want to click
if (
Math.abs(event.clientX - pos.current.x) < threshold &&
Math.abs(event.clientY - pos.current.y) < threshold
) {
target?.focus();
target?.click();
}
},
[target, threshold]
);
return target
? {
onMouseDown,
onMouseUp,
}
: {};
}
/**
* Generates Box props applicable to a clickable ancestor container (e.g. a card).
*
* Intended to be used together with useAccessibleClickHandlers spread on the same ancestor component,
* and generateTargetStyles spread on a child CTA or click target.
*
* Based on guidance seen here: https://inclusive-components.design/cards/
*
* @param focusStyles Styles to apply to the container during focus state.
* @param hoverStyles Styles to apply to the container during hover state.
*/
export function generateContainerBoxProps(
focusStyles: CSSObject,
hoverStyles: CSSObject
): BoxProps {
return {
cursor: "pointer",
_hover: hoverStyles,
// Note: _focusWithin is intentionally after _hover to take precedence if they conflict
_focusWithin: focusStyles,
};
}
/**
* Generates styles applicable to the child intended to be the click target in a clickable ancestor container.
*
* Intended to be used together with useAccessibleClickHandlers and generateContainerBoxProps spread on an
* ancestor component.
*
* This primarily supports cases where there is a single specific CTA displayed within e.g. a card but we want
* the entire card to be clickable. It can also be used in cases where no CTA should be visible, by wrapping
* the element in question with Chakra's VisuallyHidden component.
*
* Based on guidance seen here: https://inclusive-components.design/cards/
*/
export function generateTargetStyles() {
return {
// This is intentional, paired with passing styles to generateContainerBoxProps for :focus-within
_focus: { outline: "none" } as const,
};
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment