From 0b231c1c37b19507ec7db0f70a7bae0a0c4c6770 Mon Sep 17 00:00:00 2001 From: Germain Date: Thu, 10 Aug 2023 11:56:10 +0100 Subject: [PATCH] Improvements to the Avatar component (#57) Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> --- src/components/Avatar/Avatar.module.css | 14 +++ src/components/Avatar/Avatar.stories.tsx | 34 ++++++ src/components/Avatar/Avatar.tsx | 125 ++++++++++++++++------- src/utils/SuspenseImg.tsx | 1 + 4 files changed, 138 insertions(+), 36 deletions(-) diff --git a/src/components/Avatar/Avatar.module.css b/src/components/Avatar/Avatar.module.css index f27442a7..11a41880 100644 --- a/src/components/Avatar/Avatar.module.css +++ b/src/components/Avatar/Avatar.module.css @@ -27,6 +27,20 @@ limitations under the License. user-select: none; } +button.avatar { + /** + * The avatar can be a button element, we need to reset its style + */ + padding: 0; + border: 0; + appearance: none; + cursor: pointer; +} + +button.avatar:disabled { + cursor: not-allowed; +} + .avatar, .image { aspect-ratio: 1 / 1; diff --git a/src/components/Avatar/Avatar.stories.tsx b/src/components/Avatar/Avatar.stories.tsx index b25f820c..ea28832e 100644 --- a/src/components/Avatar/Avatar.stories.tsx +++ b/src/components/Avatar/Avatar.stories.tsx @@ -46,6 +46,13 @@ Square.args = { type: "square", }; +export const Button = Template.bind({}); +Button.args = { + type: "round", + as: "button", + onClick: () => console.log("clicked!"), +}; + export const NoImageFallback = Template.bind({}); NoImageFallback.args = { src: "", @@ -56,3 +63,30 @@ LargeNoImageFallback.args = { src: "", size: "128px", }; + +const ImageLessCollection: StoryFn = (args) => ( + <> + +   + +   + +   + +   + +   + +   + +   + +   + +); + +export const AllAvatars = ImageLessCollection.bind({}); +AllAvatars.args = { + src: "", + size: "36px", +}; diff --git a/src/components/Avatar/Avatar.tsx b/src/components/Avatar/Avatar.tsx index 5e7c6fba..bc2a1e00 100644 --- a/src/components/Avatar/Avatar.tsx +++ b/src/components/Avatar/Avatar.tsx @@ -21,16 +21,67 @@ import { SuspenseImg } from "../../utils/SuspenseImg"; import styles from "./Avatar.module.css"; import { useIdColorHash } from "./useIdColorHash"; -type AvatarProps = JSX.IntrinsicElements["span"] & { +type AvatarProps = ( + | JSX.IntrinsicElements["button"] + | JSX.IntrinsicElements["span"] +) & { + /** + * The avatar image URL, if any. + */ src?: React.ComponentProps["src"]; + /** + * The Matrix ID, Room ID, or Alias to generate the color when no image source + * is provided. Also used as a fallback when name is empty. + */ id: string; + /** + * The name used for the initial letter displayed when no image source is provided. + */ name: string; + /** + * Defines the avatar type, typically round, square is usually for spaces. + * @default "round" + */ type?: "square" | "round"; + /** + * The avatar size in CSS units, e.g. `"24px"`. + */ size?: CSSStyleDeclaration["height"]; + /** + * On click handler, will turn the avatar into a button element. + */ + onClick?: (e: React.MouseEvent) => void; + /** + * Key down handler, will turn the avatar into a button element. + */ + onKeyDown?: (e: React.KeyboardEvent) => void; + /** + * Key up handler, will turn the avatar into a button element. + */ + onKeyUp?: (e: React.KeyboardEvent) => void; + /** + * Callback when the image has failed to load. + */ onError?: React.ComponentProps["onError"]; }; -export const Avatar = forwardRef(function Avatar( +/** + * Some props warrant that the avatar become a button for accessibility purposes + * @param props Avatar props + * @returns whether the avatar should be a button or not + */ +function shouldBeAButton(props: Partial): boolean { + return !!(props.onClick || props.onKeyDown || props.onKeyUp); +} + +/** + * Avatar component that will fallback to an initial letter over a coloured + * background if no source is provided or if the source has failed to load. + */ +export const Avatar = forwardRef< + HTMLSpanElement | HTMLButtonElement, + AvatarProps +>(function Avatar( { src, id, @@ -47,39 +98,41 @@ export const Avatar = forwardRef(function Avatar( const hash = useIdColorHash(id); const fallbackInitial = <>{getInitialLetter(name)}; - return ( - - {!src ? ( - fallbackInitial - ) : ( - - - - )} - + return React.createElement( + shouldBeAButton(props) ? "button" : "span", + { + ref, + role: "img", + title: id, + "aria-label": "", + ...props, + "data-type": type, + "data-color": hash, + className: classnames(styles.avatar, className), + style: { + ...style, + "--cpd-avatar-size": size, + } as React.CSSProperties, + }, + [ + <> + {!src ? ( + fallbackInitial + ) : ( + + + + )} + , + ], ); }); diff --git a/src/utils/SuspenseImg.tsx b/src/utils/SuspenseImg.tsx index 3e4aeae0..cedaec4a 100644 --- a/src/utils/SuspenseImg.tsx +++ b/src/utils/SuspenseImg.tsx @@ -56,6 +56,7 @@ export const SuspenseImg: React.FC = ({ src, ...props }) => { imgCache.read(src); return (