Skip to content

Commit

Permalink
fix(polymorphic): types for avatar
Browse files Browse the repository at this point in the history
  • Loading branch information
mauroreisvieira committed Jan 16, 2025
1 parent 9c03897 commit 5147156
Show file tree
Hide file tree
Showing 3 changed files with 140 additions and 127 deletions.
223 changes: 111 additions & 112 deletions packages/react/src/avatar/src/Avatar.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import React, { useCallback, useState } from "react";
import React, { forwardRef, useCallback, useState, type ElementType } from "react";
// Compound Component
import { AvatarGroup } from "./AvatarGroup";
// Hooks
import { useBem } from "@stewed/hooks";
// Tokens
import { components } from "@stewed/tokens";
// Types
import { type DistributiveOmit, fixedForwardRef } from "../../types";
import type { CombinedProps } from "../../types";
// Styles
import styles from "./styles/index.module.scss";

// Default element type to be used when 'as' prop is not provided.
const defaultElement = "div";

/**
Expand All @@ -18,55 +19,57 @@ const defaultElement = "div";
* This interface extends the properties of a default HTML element (e.g., `img`)
* while omitting the `children` property to allow a custom rendering approach.
*
* @template T - The type of the element being used for the Avatar. Defaults to the type of `defaultElement`.
* @template T - The type of the element being used for the Avatar.
*/
export interface AvatarProps<T = typeof defaultElement>
extends Omit<React.ComponentProps<typeof defaultElement>, "children"> {
/** The name associated with the avatar. */
name?: string;
/**
* Specifies the type of element to use as the avatar.
* @default div
*/
as?: T;
/**
* Defines the skin color of the avatar.
* @default primary
*/
skin?:
| "primary"
| "secondary"
| "neutral"
| "neutral-faded"
| "critical"
| "success"
| "info"
| "warning";
/**
* Specifies the size of the avatar.
* @default md
*/
size?: "xs" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl" | "5xl" | "6xl";
/**
* Determines the shape of the avatar.
* @default circle
*/
shape?: "circle" | "square";
/**
* Determines the appearance of the avatar.
* @default filled
*/
appearance?: "filled" | "outline";
/** Additional CSS class to apply to the avatar. */
className?: string;
/** The props to be added on image element. */
image?: React.ComponentPropsWithoutRef<"img"> & {
/** The ref to attach to the `<img />` element. */
ref?: React.Ref<HTMLImageElement>;
};
/** Slot for an SVG icon, a possible alternative to using an image. */
svgIcon?: React.ComponentPropsWithoutRef<"svg">;
}
type AvatarProps<T extends ElementType = ElementType> = CombinedProps<
{
/**
* Specifies the type of element to use as the avatar.
* @default div
*/
as?: T;
/** The name associated with the avatar. */
name?: string;
/**
* Defines the skin color of the avatar.
* @default primary
*/
skin?:
| "primary"
| "secondary"
| "neutral"
| "neutral-faded"
| "critical"
| "success"
| "info"
| "warning";
/**
* Specifies the size of the avatar.
* @default md
*/
size?: "xs" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl" | "5xl" | "6xl";
/**
* Determines the shape of the avatar.
* @default circle
*/
shape?: "circle" | "square";
/**
* Determines the appearance of the avatar.
* @default filled
*/
appearance?: "filled" | "outline";
/** Additional CSS class to apply to the avatar. */
className?: string;
/** The props to be added on image element. */
image?: React.ComponentPropsWithoutRef<"img"> & {
/** The ref to attach to the `<img />` element. */
ref?: React.Ref<HTMLImageElement>;
};
/** Slot for an SVG icon, a possible alternative to using an image. */
svgIcon?: React.ComponentPropsWithoutRef<"svg">;
},
T
>;

/**
* This component displays an avatar component.
Expand All @@ -85,80 +88,76 @@ export interface AvatarProps<T = typeof defaultElement>
* @param props - The props for the Avatar component.
* @return The rendered Avatar component.
*/
export const Root = fixedForwardRef(function Avatar<T extends React.ElementType>(
{
as,
size = "md",
skin = "primary",
appearance = "filled",
shape = "circle",
className,
image,
svgIcon,
name,
...props
}: AvatarProps<T> &
DistributiveOmit<
React.ComponentPropsWithRef<React.ElementType extends T ? typeof defaultElement : T>,
"as"
>,
ref: React.ForwardedRef<unknown>
): React.ReactElement {
// Component to render based on the 'as' prop
const Comp = as || defaultElement;
const Root = forwardRef(
(
{
as,
size = "md",
skin = "primary",
appearance = "filled",
shape = "circle",
className,
image,
svgIcon,
name,
...props
}: AvatarProps,
ref: React.Ref<Element>
) => {
// Component to render based on the 'as' prop
const Comp = as || defaultElement;

// Importing useBem to handle BEM class names
const { getBlock, getElement } = useBem({ block: components.Avatar, styles });
// Importing useBem to handle BEM class names
const { getBlock, getElement } = useBem({ block: components.Avatar, styles });

// Generating CSS classes based on component props and styles
const cssClasses = {
root: getBlock({
modifiers: [appearance, shape, size, skin, as === "button" && "button"],
extraClasses: className
}),
img: getElement(["img"], image?.className)
};
// Generating CSS classes based on component props and styles
const cssClasses = {
root: getBlock({
modifiers: [appearance, shape, size, skin, as === "button" && "button"],
extraClasses: className
}),
img: getElement(["img"], image?.className)
};

// State to track if there was an error while loading the image
const [imageError, setImageError] = useState(false);
// State to track if there was an error while loading the image
const [imageError, setImageError] = useState(false);

// Extract initials from the provided name, capturing the first two uppercase letters
// and converting to uppercase, e.g., "John Doe" => "JD"
const initials = name?.match(/[A-Z]/g)?.join("").slice(0, 2).toUpperCase();
// Extract initials from the provided name, capturing the first two uppercase letters
// and converting to uppercase, e.g., "John Doe" => "JD"
const initials = name?.match(/[A-Z]/g)?.join("").slice(0, 2).toUpperCase();

// Callback to handle image load errors
// Sets the error state to true and triggers any optional `onError` event handler passed in `image`
const onHandleError = useCallback<React.ReactEventHandler<HTMLImageElement>>(
(event) => {
setImageError(true);
image?.onError?.(event);
},
[image]
);
// Callback to handle image load errors
// Sets the error state to true and triggers any optional `onError` event handler passed in `image`
const onHandleError = useCallback<React.ReactEventHandler<HTMLImageElement>>(
(event) => {
setImageError(true);
image?.onError?.(event);
},
[image]
);

return (
<Comp ref={ref} className={cssClasses.root} {...props}>
{svgIcon ? (
svgIcon
) : (
<>
{image && !imageError ? (
<>
return (
<Comp ref={ref} className={cssClasses.root} {...props}>
{svgIcon ? (
svgIcon
) : (
<>
{image && !imageError ? (
<img

Check warning on line 146 in packages/react/src/avatar/src/Avatar.tsx

View workflow job for this annotation

GitHub Actions / 🔎 Validate Changes

Non-interactive elements should not be assigned mouse or keyboard event listeners
{...image}
className={cssClasses.img}
alt={image?.alt || name}
onError={onHandleError}
/>
</>
) : (
initials
)}
</>
)}
</Comp>
);
});
) : (
initials
)}
</>
)}
</Comp>
);
}
) as <T extends ElementType = typeof defaultElement>(props: AvatarProps<T>) => React.ReactElement;

// Compound component composition
export const Avatar = Object.assign(Root, {
Expand Down
26 changes: 26 additions & 0 deletions packages/react/src/types/Merge.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { JSX, ElementType } from "react";

/**
* Merges two types, `T` and `U`, by excluding properties from `T` that are present in `U` and then combining them with `U`.
* This ensures that properties from `U` override those from `T` if they share the same key.
Expand All @@ -7,3 +9,27 @@
* @returns A new type that combines `T` and `U`, with `U`'s properties overriding `T`'s.
*/
export type Merge<T, U> = Omit<T, keyof U> & U;

/**
* Represents the intrinsic attributes for a given element type.
*
* This type combines the intrinsic attributes of a JSX element or a React
* component constructor with the props that can be passed to it.
*
* @template E - The type of the element, which can be a key of JSX.IntrinsicElements or a React component constructor.
*/
export type IntrinsicAttributes<
E extends keyof JSX.IntrinsicElements | React.JSXElementConstructor<unknown>
> = JSX.LibraryManagedAttributes<E, React.ComponentPropsWithRef<E>>;

/**
* Combines custom props with intrinsic attributes for a flexible component.
*
* This type allows a component to accept both its own props and the intrinsic
* attributes of the element it renders as. It omits any keys from the intrinsic
* attributes that are already defined in the custom props to avoid conflicts.
*
* @template I - The type of the custom props for the component.
* @template E - The type of the element that the component can render as.
*/
export type CombinedProps<I, E extends ElementType> = I & Omit<IntrinsicAttributes<E>, keyof I>;
18 changes: 3 additions & 15 deletions packages/utilities/src/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,6 @@ function testUserAgent(re: RegExp): boolean {
: false;
}

/**
* Tests the given regular expression against the platform string in the browser window.
*
* @param re - The regular expression to test.
* @returns A boolean indicating whether the regular expression matches the platform string.
*/
function testPlatform(re: RegExp): boolean {
return typeof window !== "undefined" && window.navigator
? re.test(window.navigator.platform)
: false;
}

/**
* Checks if the code is running in a browser environment.
*
Expand All @@ -34,22 +22,22 @@ export const isBrowser = (): boolean => typeof window !== "undefined";
*
* @returns A boolean indicating whether the current platform is macOS.
*/
export const isMac = (): boolean => testPlatform(/^Mac/);
export const isMac = (): boolean => testUserAgent(/^Mac/);

/**
* Checks if the current platform is iPhone.
*
* @returns A boolean indicating whether the current platform is iPhone.
*/
export const isIPhone = (): boolean => testPlatform(/^iPhone/);
export const isIPhone = (): boolean => testUserAgent(/^iPhone/);

/**
* Checks if the current platform is iPad.
*
* @returns A boolean indicating whether the current platform is iPad.
*/
export const isIPad = (): boolean =>
testPlatform(/^iPad/) || (isMac() && navigator.maxTouchPoints > 1);
testUserAgent(/^iPad/) || (isMac() && navigator.maxTouchPoints > 1);

/**
* Checks if the current platform is iOS.
Expand Down

0 comments on commit 5147156

Please sign in to comment.