feat: user created, avatar improvements

This commit is contained in:
Junior Garcia 2023-02-17 22:21:33 -03:00
parent 015526b016
commit a6d8b976c8
18 changed files with 328 additions and 176 deletions

View File

@ -40,7 +40,6 @@
"@nextui-org/system": "workspace:*",
"@nextui-org/theme": "workspace:*",
"@nextui-org/shared-utils": "workspace:*",
"@nextui-org/shared-css": "workspace:*",
"@nextui-org/dom-utils": "workspace:*",
"@nextui-org/use-image": "workspace:*",
"@react-aria/focus": "^3.9.0",

View File

@ -30,6 +30,14 @@ export interface UseAvatarGroupProps extends HTMLNextUIProps<"div"> {
* Whether the avatars are bordered
*/
isBordered?: AvatarProps["isBordered"];
/**
* Whether the avatars are disabled
*/
isDisabled?: AvatarProps["isDisabled"];
/**
* Whether the avatars should be displayed in a grid
*/
isGrid?: boolean;
/**
* The maximum number of visible avatars
* @default 5
@ -49,7 +57,9 @@ export type ContextType = {
size?: AvatarProps["size"];
color?: AvatarProps["color"];
radius?: AvatarProps["radius"];
isGrid?: boolean;
isBordered?: AvatarProps["isBordered"];
isDisabled?: AvatarProps["isDisabled"];
};
export function useAvatarGroup(props: UseAvatarGroupProps) {
@ -61,8 +71,10 @@ export function useAvatarGroup(props: UseAvatarGroupProps) {
size,
color,
radius,
isBordered,
children,
isBordered,
isDisabled,
isGrid,
className,
...otherProps
} = props;
@ -75,10 +87,12 @@ export function useAvatarGroup(props: UseAvatarGroupProps) {
size,
color,
radius,
isGrid,
isBordered,
isDisabled,
};
const styles = avatarGroup({className});
const styles = avatarGroup({className, isGrid});
const validChildren = getValidChildren(children);
const childrenWithinMax = max ? validChildren.slice(0, max) : validChildren;
@ -91,7 +105,7 @@ export function useAvatarGroup(props: UseAvatarGroupProps) {
const childProps = {
className: clsx(
isFirstAvatar ? "ml-0" : "-ml-2",
isFirstAvatar ? "ml-0" : !isGrid ? "-ml-2" : "",
isLastAvatar && remainingCount < 1 ? "hover:-translate-x-0" : "",
),
};

View File

@ -11,7 +11,8 @@ import {useImage} from "@nextui-org/use-image";
import {useAvatarGroupContext} from "./avatar-group-context";
export interface UseAvatarProps extends HTMLNextUIProps<"span", AvatarVariantProps> {
export interface UseAvatarProps
extends Omit<HTMLNextUIProps<"span", AvatarVariantProps>, "children" | "isFocusVisible"> {
/**
* Ref to the DOM node.
*/
@ -98,6 +99,8 @@ export function useAvatar(props: UseAvatarProps) {
radius = groupContext?.radius ?? "full",
size = groupContext?.size ?? "md",
isBordered = groupContext?.isBordered ?? false,
isDisabled = groupContext?.isDisabled ?? false,
isInGridGroup = groupContext?.isGrid ?? false,
isFocusable = false,
getInitials = safeText,
ignoreFallback = false,
@ -133,7 +136,16 @@ export function useAvatar(props: UseAvatarProps) {
const {isFocusVisible, focusProps} = useFocusRing();
const slots = avatar({color, radius, size, isBordered, isFocusVisible, isInGroup});
const slots = avatar({
color,
radius,
size,
isBordered,
isFocusVisible,
isDisabled,
isInGroup,
isInGridGroup,
});
const imgStyles = clsx(
"transition-opacity !duration-500 opacity-0 data-[loaded=true]:opacity-100",
@ -154,7 +166,7 @@ export function useAvatar(props: UseAvatarProps) {
}),
...mergeProps(otherProps, canBeFocused ? focusProps : {}),
}),
[canBeFocused, slots, baseStyles, buttonStyles],
[canBeFocused, slots, baseStyles, buttonStyles, focusProps, otherProps],
);
return {
@ -172,7 +184,6 @@ export function useAvatar(props: UseAvatarProps) {
imgStyles,
getAvatarProps,
getInitials,
...otherProps,
};
}

View File

@ -39,6 +39,8 @@ const pics = [
"https://i.pravatar.cc/300?u=a042581f4e29026707d",
"https://i.pravatar.cc/300?u=a042581f4e29026709d",
"https://i.pravatar.cc/300?u=a042581f4f29026709d",
"https://i.pravatar.cc/300?u=a042581f4e29026710d",
"https://i.pravatar.cc/300?u=a042581f4e29026711d",
];
const Template: ComponentStory<typeof AvatarGroup> = (args: AvatarGroupProps) => (
@ -48,6 +50,10 @@ const Template: ComponentStory<typeof AvatarGroup> = (args: AvatarGroupProps) =>
<Avatar src={pics[2]} />
<Avatar src={pics[3]} />
<Avatar src={pics[4]} />
<Avatar src={pics[5]} />
<Avatar src={pics[2]} />
<Avatar src={pics[3]} />
<Avatar src={pics[6]} />
</AvatarGroup>
);
@ -57,6 +63,21 @@ Default.args = {
isBordered: true,
};
export const Grid = Template.bind({});
Grid.args = {
color: "primary",
isBordered: true,
max: 7,
isGrid: true,
};
export const isDisabled = Template.bind({});
isDisabled.args = {
color: "warning",
isBordered: true,
isDisabled: true,
};
export const WithMaxCount = Template.bind({});
WithMaxCount.args = {
color: "primary",

View File

@ -37,8 +37,20 @@ export default {
const Template: ComponentStory<typeof Avatar> = (args: AvatarProps) => <Avatar {...args} />;
export const Default = Template.bind({});
Default.args = {
name: "Junior",
Default.args = {};
export const WithText = Template.bind({});
WithText.args = {
name: "JW",
color: "error",
};
export const isDisabled = Template.bind({});
isDisabled.args = {
src: "https://i.pravatar.cc/300?u=a042581f4e29026709d",
color: "secondary",
isBordered: true,
isDisabled: true,
};
export const WithImage = Template.bind({});
@ -68,11 +80,27 @@ export const Custom = Template.bind({});
Custom.args = {
icon: <Activity fill="currentColor" size={20} />,
radius: "xl",
classes: {
styles: {
base: "shadow-lg bg-cyan-200 dark:bg-cyan-800",
},
};
export const CustomSize = Template.bind({});
CustomSize.args = {
styles: {
base: "w-32 h-32 text-md",
},
};
export const CustomSizeImg = Template.bind({});
CustomSizeImg.args = {
src: "https://i.pravatar.cc/300?u=a042581f4e29026705d",
name: "Junior",
styles: {
base: "w-32 h-32 text-md",
},
};
export const DefaultIcon = Template.bind({});
DefaultIcon.args = {
styles: {

View File

@ -38,10 +38,8 @@
},
"dependencies": {
"@nextui-org/dom-utils": "workspace:*",
"@nextui-org/shared-css": "workspace:*",
"@nextui-org/shared-utils": "workspace:*",
"@nextui-org/system": "workspace:*",
"@nextui-org/use-is-mounted": "workspace:*",
"@nextui-org/theme": "workspace:*",
"@react-aria/link": "^3.3.4",
"@react-aria/utils": "^3.14.0",

View File

@ -2,6 +2,7 @@ module.exports = {
stories: [
"../../link/stories/*.stories.@(js|jsx|ts|tsx)",
"../../avatar/stories/*.stories.@(js|jsx|ts|tsx)",
"../../user/stories/*.stories.@(js|jsx|ts|tsx)",
],
staticDirs: ["../public"],
addons: [

View File

@ -39,15 +39,15 @@
"dependencies": {
"@nextui-org/system": "workspace:*",
"@nextui-org/avatar": "workspace:*",
"@nextui-org/link": "workspace:*",
"@nextui-org/theme": "workspace:*",
"@nextui-org/shared-utils": "workspace:*",
"@nextui-org/shared-css": "workspace:*",
"@nextui-org/dom-utils": "workspace:*",
"@react-aria/focus": "^3.9.0",
"@react-aria/utils": "^3.14.0"
},
"devDependencies": {
"clean-package": "2.1.1",
"@nextui-org/link": "workspace:*",
"react": "^17.0.2"
},
"tsup": {

View File

@ -1,10 +1,19 @@
import type {UserVariantProps, SlotsToClasses, UserSlots} from "@nextui-org/theme";
import type {AvatarProps} from "@nextui-org/avatar";
import {ReactNode, useMemo} from "react";
import {ReactNode, useMemo, useCallback} from "react";
import {useFocusRing} from "@react-aria/focus";
import {HTMLNextUIProps} from "@nextui-org/system";
export interface UseUserProps extends HTMLNextUIProps<"div", AvatarProps> {
import {user} from "@nextui-org/theme";
import {ReactRef, clsx} from "@nextui-org/shared-utils";
import {useDOMRef} from "@nextui-org/dom-utils";
import {mergeProps} from "@react-aria/utils";
export interface UseUserProps
extends Omit<HTMLNextUIProps<"div", UserVariantProps>, "children" | "isFocusVisible"> {
/**
* Ref to the DOM node.
*/
ref?: ReactRef<HTMLDivElement | null>;
/**
* The user name.
*/
@ -13,41 +22,104 @@ export interface UseUserProps extends HTMLNextUIProps<"div", AvatarProps> {
* The user information, like email, phone, etc.
*/
description?: ReactNode | string;
/**
* Whether the user can be focused.
* @default false
*/
isFocusable?: boolean;
/**
* The user avatar props
* @see https://nextui.org/docs/components/avatar
*/
avatarProps?: AvatarProps;
/**
* Classname or List of classes to change the styles of the avatar.
* if `className` is passed, it will be added to the base slot.
*
* @example
* ```ts
* <User styles={{
* base:"base-classes",
* wrapper: "wrapper-classes",
* name: "name-classes",
* description: "description-classes",
* }} />
* ```
*/
styles?: SlotsToClasses<UserSlots>;
}
export function useUser(props: UseUserProps) {
const {as, className, css, name, description, ...otherProps} = props;
const {isFocusVisible, focusProps} = useFocusRing();
const userCss = useMemo(() => {
if (as === "button") {
return {
// reset button styles
p: 0,
m: 0,
borderRadius: "$xs",
background: "none",
appearance: "none",
outline: "none",
border: "none",
cursor: "pointer",
};
}
return {};
}, [as]);
return {
const {
as,
userCss,
className,
ref,
css,
name,
description,
isFocusVisible,
className,
styles,
isFocusable = false,
avatarProps = {
isFocusable: false,
name: typeof name === "string" ? name : undefined,
},
...otherProps
} = props;
const domRef = useDOMRef(ref);
const {isFocusVisible, focusProps} = useFocusRing();
const Component = as || "div";
const canBeFocused = useMemo(() => {
return isFocusable || as === "button";
}, [isFocusable, as]);
const slots = user({isFocusVisible});
const baseStyles = clsx(styles?.base, className);
const buttonStyles = useMemo(() => {
if (as !== "button") return "";
// reset button styles
return [
"p-0",
"m-0",
"bg-none",
"radius-none",
"appearance-none",
"outline-none",
"border-none",
"cursor-pointer",
];
}, [as]);
const getUserProps = useCallback(
() => ({
tabIndex: canBeFocused ? 0 : -1,
className: slots.base({
class: clsx(baseStyles, buttonStyles),
}),
...mergeProps(otherProps, canBeFocused ? focusProps : {}),
}),
[canBeFocused, slots, baseStyles, focusProps, otherProps],
);
return {
Component,
domRef,
className,
css,
slots,
name,
description,
styles,
baseStyles,
focusProps,
...otherProps,
avatarProps,
getUserProps,
};
}

View File

@ -1,38 +0,0 @@
import {forwardRef} from "@nextui-org/system";
import {useDOMRef} from "@nextui-org/dom-utils";
import {clsx, __DEV__} from "@nextui-org/shared-utils";
import {Link, LinkProps} from "@nextui-org/link";
export interface UserLinkProps extends LinkProps {}
export const UserLink = forwardRef<UserLinkProps, "a">((props, ref) => {
const {
rel = "noopener",
color = "primary",
target = "_blank",
className,
children,
...otherProps
} = props;
const domRef = useDOMRef(ref);
return (
<Link
ref={domRef}
className={clsx("nextui-user-link", className)}
color={color}
rel={rel}
target={target}
{...otherProps}
>
{children}
</Link>
);
});
if (__DEV__) {
UserLink.displayName = "NextUI.UserLink";
}
UserLink.toString = () => ".nextui-user-link";

View File

@ -1,82 +1,30 @@
import {mergeProps} from "@react-aria/utils";
import {forwardRef} from "@nextui-org/system";
import {useDOMRef} from "@nextui-org/dom-utils";
import {clsx, __DEV__} from "@nextui-org/shared-utils";
import {__DEV__} from "@nextui-org/shared-utils";
import {Avatar} from "@nextui-org/avatar";
import {UserLink} from "./user-link";
import {StyledUser, StyledUserInfo, StyledUserName, StyledUserDesc} from "./user.styles";
import {UseUserProps, useUser} from "./use-user";
export interface UserProps extends UseUserProps {}
type CompundUser = {
Link: typeof UserLink;
};
const User = forwardRef<UserProps, "div", CompundUser>((props, ref) => {
const {
// user props
css,
name,
userCss,
isFocusVisible,
className,
focusProps,
description,
children,
// avatar props, TODO: this should come from a "avatarProps" prop.
alt,
src,
squared,
size,
zoomed,
bordered,
color,
pointer,
text,
...otherProps
} = useUser(props);
const domRef = useDOMRef(ref);
const User = forwardRef<UserProps, "div">((props, ref) => {
const {Component, domRef, name, slots, description, avatarProps, getUserProps} = useUser({
ref,
...props,
});
return (
<StyledUser
ref={domRef}
className={clsx("nextui-user", className)}
css={{
...userCss,
...css,
}}
{...mergeProps(focusProps, otherProps)}
isFocusVisible={isFocusVisible}
>
<Avatar
alt={alt}
bordered={bordered}
className="nextui-user-avatar"
color={color}
pointer={pointer}
size={size}
squared={squared}
src={src}
text={text}
zoomed={zoomed}
/>
<StyledUserInfo className="nextui-user-info">
<StyledUserName className="nextui-user-name">{name}</StyledUserName>
<StyledUserDesc className="nextui-user-desc">{description || children}</StyledUserDesc>
</StyledUserInfo>
</StyledUser>
<Component ref={domRef} {...getUserProps()}>
<Avatar {...avatarProps} />
<div className={slots.wrapper()}>
<span className={slots.name()}>{name}</span>
<span className={slots.description()}>{description}</span>
</div>
</Component>
);
});
User.Link = UserLink;
if (__DEV__) {
User.displayName = "NextUI.User";
}
User.toString = () => ".nextui-user";
export default User;

View File

@ -1,27 +1,66 @@
import React from "react";
import {Meta} from "@storybook/react";
import {ComponentStory, ComponentMeta} from "@storybook/react";
import {Link} from "@nextui-org/link";
import {User} from "../src";
import {User, UserProps} from "../src";
export default {
title: "Display/User",
component: User,
} as Meta;
} as ComponentMeta<typeof User>;
const url = "https://avatars.githubusercontent.com/u/30373425?v=4";
export const Default = () => <User squared name="Junior García" src={url} />;
const Template: ComponentStory<typeof User> = (args: UserProps) => <User {...args} />;
export const Description = () => (
<User squared name="Junior García" src={url}>
Software Developer
</User>
);
export const Link = () => {
return (
<User squared name="Junior García" src={url}>
<User.Link href="https://twitter.com/jrgarciadev">@jrgarciadev</User.Link>
</User>
);
export const Default = Template.bind({});
Default.args = {
name: "Junior Garcia",
avatarProps: {
src: url,
},
};
export const isFocusable = Template.bind({});
isFocusable.args = {
name: "Junior Garcia",
isFocusable: true,
avatarProps: {
src: url,
},
};
export const WithDefaultAvatar = Template.bind({});
WithDefaultAvatar.args = {
name: "Junior Garcia",
avatarProps: {
name: "Junior Garcia",
getInitials: (name) =>
name
.split(" ")
.map((n) => n[0])
.join(""),
},
};
export const WithDescription = Template.bind({});
WithDescription.args = {
name: "Junior Garcia",
description: "Software Engineer",
avatarProps: {
src: url,
},
};
export const WithLinkDescription = Template.bind({});
WithLinkDescription.args = {
name: "Junior Garcia",
description: (
<Link href="https://twitter.com/jrgarciadev" size="xs">
@jrgarciadev
</Link>
),
avatarProps: {
src: url,
},
};

View File

@ -1,4 +1,4 @@
import {tv} from "tailwind-variants";
import {tv, VariantProps} from "tailwind-variants";
/**
* AvatarGroup wrapper tv component
@ -12,6 +12,13 @@ import {tv} from "tailwind-variants";
*/
const avatarGroup = tv({
base: "flex items-center justify-center h-auto w-max-content",
variants: {
isGrid: {
true: "inline-grid grid-cols-4 gap-3",
},
},
});
export type AvatarGroupVariantProps = VariantProps<typeof avatarGroup>;
export {avatarGroup};

View File

@ -1,6 +1,6 @@
import {tv, type VariantProps} from "tailwind-variants";
import {translateCenterClasses} from "../../utils";
import {translateCenterClasses, ringClasses} from "../../utils";
/**
* Avatar wrapper tv component
@ -106,9 +106,14 @@ const avatar = tv({
base: "ring-2 ring-offset-2 ring-offset-background dark:ring-offset-background-dark",
},
},
isDisabled: {
true: {
base: "opacity-50",
},
},
isFocusVisible: {
true: {
base: "outline-none ring-2 !ring-primary ring-offset-2 ring-offset-background dark:ring-offset-background-dark",
base: [...ringClasses],
},
},
isInGroup: {
@ -116,6 +121,11 @@ const avatar = tv({
base: "-ml-2 hover:-translate-x-3 transition-transform",
},
},
isInGridGroup: {
true: {
base: "m-0 hover:translate-x-0",
},
},
},
defaultVariants: {
size: "md",

View File

@ -1,3 +1,4 @@
export * from "./link";
export * from "./avatar";
export * from "./avatar-group";
export * from "./user";

View File

@ -0,0 +1,38 @@
import {tv, VariantProps} from "tailwind-variants";
import {ringClasses} from "../../utils";
/**
* User wrapper tv component
*
* const {base, wrapper, name, description} = user({...})
*
* @example
* <div className={base())}>
* // avatar element @see avatar
* <div className={wrapper())}>
* <span className={name())}>user name</span>
* <span className={description())}>user description</span>
* </div>
* </div>
*/
const user = tv({
slots: {
base: "inline-flex items-center justify-center gap-2",
wrapper: "inline-flex flex-col items-start",
name: "text-sm text-foreground dark:text-foreground-dark",
description: "text-xs text-neutral-500",
},
variants: {
isFocusVisible: {
true: {
base: [...ringClasses, "rounded-sm"],
},
},
},
});
export type UserVariantProps = VariantProps<typeof user>;
export type UserSlots = keyof ReturnType<typeof user>;
export {user};

View File

@ -11,6 +11,15 @@ export const focusVisibleClasses = [
"dark:focus-visible:ring-offset-background-dark",
];
export const ringClasses = [
"outline-none",
"ring-2",
"!ring-primary",
"ring-offset-2",
"ring-offset-background",
"dark:ring-offset-background-dark",
];
/**
* This classes centers the element by using absolute positioning.
*/

12
pnpm-lock.yaml generated
View File

@ -334,7 +334,6 @@ importers:
packages/components/avatar:
specifiers:
'@nextui-org/dom-utils': workspace:*
'@nextui-org/shared-css': workspace:*
'@nextui-org/shared-icons': workspace:*
'@nextui-org/shared-utils': workspace:*
'@nextui-org/system': workspace:*
@ -347,7 +346,6 @@ importers:
react: ^17.0.2
dependencies:
'@nextui-org/dom-utils': link:../../utilities/dom-utils
'@nextui-org/shared-css': link:../../utilities/shared-css
'@nextui-org/shared-utils': link:../../utilities/shared-utils
'@nextui-org/system': link:../../core/system
'@nextui-org/theme': link:../../core/theme
@ -569,11 +567,9 @@ importers:
packages/components/link:
specifiers:
'@nextui-org/dom-utils': workspace:*
'@nextui-org/shared-css': workspace:*
'@nextui-org/shared-utils': workspace:*
'@nextui-org/system': workspace:*
'@nextui-org/theme': workspace:*
'@nextui-org/use-is-mounted': workspace:*
'@react-aria/focus': ^3.9.0
'@react-aria/link': ^3.3.4
'@react-aria/utils': ^3.14.0
@ -582,11 +578,9 @@ importers:
react: ^17.0.2
dependencies:
'@nextui-org/dom-utils': link:../../utilities/dom-utils
'@nextui-org/shared-css': link:../../utilities/shared-css
'@nextui-org/shared-utils': link:../../utilities/shared-utils
'@nextui-org/system': link:../../core/system
'@nextui-org/theme': link:../../core/theme
'@nextui-org/use-is-mounted': link:../../hooks/use-is-mounted
'@react-aria/focus': 3.10.1_react@17.0.2
'@react-aria/link': 3.3.6_react@17.0.2
'@react-aria/utils': 3.14.2_react@17.0.2
@ -741,9 +735,9 @@ importers:
'@nextui-org/avatar': workspace:*
'@nextui-org/dom-utils': workspace:*
'@nextui-org/link': workspace:*
'@nextui-org/shared-css': workspace:*
'@nextui-org/shared-utils': workspace:*
'@nextui-org/system': workspace:*
'@nextui-org/theme': workspace:*
'@react-aria/focus': ^3.9.0
'@react-aria/utils': ^3.14.0
clean-package: 2.1.1
@ -751,13 +745,13 @@ importers:
dependencies:
'@nextui-org/avatar': link:../avatar
'@nextui-org/dom-utils': link:../../utilities/dom-utils
'@nextui-org/link': link:../link
'@nextui-org/shared-css': link:../../utilities/shared-css
'@nextui-org/shared-utils': link:../../utilities/shared-utils
'@nextui-org/system': link:../../core/system
'@nextui-org/theme': link:../../core/theme
'@react-aria/focus': 3.10.1_react@17.0.2
'@react-aria/utils': 3.14.2_react@17.0.2
devDependencies:
'@nextui-org/link': link:../link
clean-package: 2.1.1
react: 17.0.2