diff --git a/README.md b/README.md index 223b759..dbf0055 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ This repository contains a library of reusable React components extracted from t Below is a list of the components available in this library. Each component has its own detailed documentation with usage examples and a complete list of props. - [BBBAccordion](./src/components/Accordion/README.md) +- [BBBAvatar](./src/components/Avatar/README.md) - [BBButton](./src/components/Button/README.md) - [BBBCheckbox](./src/components/Checkbox/README.md) - [BBBDivider](./src/components/Divider/README.md) @@ -101,6 +102,7 @@ The following table lists the supported CSS variables for color overriding, extr | `--color-icon-default-dark`| No | rgba(255, 255, 255, 0.35) | | `--color-overlay` | No | rgba(0, 0, 0, 0.75) | | `--color-shadow-default` | No | rgb(0 35 11 / 20%) | +| `--color-user-you` | No | #19237C | **Example Usage**: ```css @@ -132,7 +134,7 @@ const StyledDiv = styled.div` ``` `colors` is grouped the same way as the table above: `neutral`, `brand`, `semantic`, `background`, -`border`, `text`, `icon`, `hover`. +`border`, `text`, `icon`, `hover`, `overlay`, `shadow`, `user`. ## Installation diff --git a/package.json b/package.json index bea56d7..b56e4f4 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,13 @@ "require": "./dist/components/Accordion.js", "default": "./dist/components/Accordion.js" }, + "./Avatar": { + "types": "./dist/types/components/Avatar/index.d.ts", + "node": "./dist/components/Avatar.js", + "import": "./dist/esm/components/Avatar/index.js", + "require": "./dist/components/Avatar.js", + "default": "./dist/components/Avatar.js" + }, "./Button": { "types": "./dist/types/components/Button/index.d.ts", "node": "./dist/components/Button.js", @@ -181,6 +188,9 @@ "Accordion": [ "dist/types/components/Accordion/index.d.ts" ], + "Avatar": [ + "dist/types/components/Avatar/index.d.ts" + ], "Button": [ "dist/types/components/Button/index.d.ts" ], diff --git a/src/components/Avatar/README.md b/src/components/Avatar/README.md new file mode 100644 index 0000000..1d98e3f --- /dev/null +++ b/src/components/Avatar/README.md @@ -0,0 +1,83 @@ +# BBBAvatar + +The `BBBAvatar` component renders a user's avatar image, falling back to their initials on a deterministically-colored background when no image is available or the image fails to load. It shows a tooltip with the full name on hover, and can highlight the current user or a speaking user, matching BBB's own avatar treatment. + +![Demo](assets/example.png) + +## Usage Example + +### Avatar with image +```jsx +import { BBBAvatar } from 'bbb-ui-components-react'; + + +``` + +### Avatar with initials fallback +```jsx +import { BBBAvatar } from 'bbb-ui-components-react'; + + +``` + +### Avatar with a custom color +```jsx +import { BBBAvatar } from 'bbb-ui-components-react'; + + +``` + +### Medium avatar +```jsx +import { BBBAvatar } from 'bbb-ui-components-react'; + + +``` + +### Large avatar +```jsx +import { BBBAvatar } from 'bbb-ui-components-react'; + + +``` + +### Moderator avatar +```jsx +import { BBBAvatar } from 'bbb-ui-components-react'; + + +``` + +### Current user's avatar +```jsx +import { BBBAvatar } from 'bbb-ui-components-react'; + + +``` + +### Talking indicator +```jsx +import { BBBAvatar } from 'bbb-ui-components-react'; + + +``` + +### Avatar without the hover tooltip +```jsx +import { BBBAvatar } from 'bbb-ui-components-react'; + + +``` + +## Props + +| Property | Type | Default | Description | +| ---------------- | -------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `name` | `string` | | Full name of the user; used to render initials, to derive a deterministic fallback color, and as the tooltip content. | +| `avatarUrl` | `string` | | URL of the user's avatar image. Falls back to initials when omitted or if the image fails to load. | +| `color` | `string` | color deterministically derived from `name` | Background color behind the initials (and border color on the image). Overrides `isYou`. | +| `size` | `'small' \| 'medium' \| 'large'` | `'medium'` | Size variant of the avatar. | +| `isModerator` | `boolean` | `false` | Renders a rounded-square shape instead of a circle, matching BBB's moderator avatar treatment. | +| `isYou` | `boolean` | `false` | Marks this avatar as belonging to the current user, applying BBB's "you" color in place of the fallback/computed color. Ignored when `color` is set. | +| `isTalking` | `boolean` | `false` | Shows a pulsing ring around the avatar, in its own color, matching BBB's talking indicator. | +| `disableTooltip` | `boolean` | `false` | Disables the tooltip that shows the full `name` on hover. | diff --git a/src/components/Avatar/assets/example.png b/src/components/Avatar/assets/example.png new file mode 100644 index 0000000..7a9463a Binary files /dev/null and b/src/components/Avatar/assets/example.png differ diff --git a/src/components/Avatar/component.stories.tsx b/src/components/Avatar/component.stories.tsx new file mode 100644 index 0000000..94bd44a --- /dev/null +++ b/src/components/Avatar/component.stories.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import BBBAvatar from './component'; +import { AVATAR_SIZE_VALUES, DEFAULT_AVATAR_SIZE } from './constants'; + +const meta = { + title: 'BBBAvatar', + component: BBBAvatar, + tags: ['autodocs'], + argTypes: { + name: { + control: 'text', + description: 'Full name of the user; used to render initials and to derive a deterministic fallback color.', + }, + avatarUrl: { + control: 'text', + description: "URL of the user's avatar image. Falls back to initials when omitted or if the image fails to load.", + }, + color: { + control: 'color', + description: 'Background color behind the initials (and border color on the image).', + }, + size: { + control: 'select', + options: AVATAR_SIZE_VALUES, + description: 'Size variant of the avatar.', + table: { defaultValue: { summary: `${DEFAULT_AVATAR_SIZE}` } }, + }, + isModerator: { + control: 'boolean', + description: "Renders a rounded-square shape instead of a circle, matching BBB's moderator avatar treatment.", + table: { defaultValue: { summary: 'false' } }, + }, + isYou: { + control: 'boolean', + description: 'Marks this avatar as belonging to the current user, applying BBB\'s "you" color in place of the fallback/computed color. Ignored when `color` is set.', + table: { defaultValue: { summary: 'false' } }, + }, + isTalking: { + control: 'boolean', + description: "Shows a pulsing ring around the avatar, in its own color, matching BBB's talking indicator.", + table: { defaultValue: { summary: 'false' } }, + }, + disableTooltip: { + control: 'boolean', + description: 'Disables the tooltip that shows the full `name` on hover.', + table: { defaultValue: { summary: 'false' } }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** Falls back to initials when no image is given, next to an avatar rendering a provided image. */ +export const Default: Story = { + render: (args) => ( +
+ + +
+ ), +}; + +/** All size variants rendered side by side. */ +export const Sizes: Story = { + render: (args) => ( +
+ {AVATAR_SIZE_VALUES.map((size) => ( + + ))} +
+ ), +}; + +/** The default circular shape next to the rounded-square shape used for moderators. */ +export const Types: Story = { + render: (args) => ( +
+ + +
+ ), +}; + +/** Different names deriving different colors from the deterministic fallback, next to an explicit custom color override. */ +export const Colors: Story = { + render: (args) => ( +
+ + + + + + + + + + +
+ ), +}; + +/** Not talking next to the pulsing ring shown while the user is speaking. */ +export const Talking: Story = { + render: (args) => ( +
+ + +
+ ), +}; diff --git a/src/components/Avatar/component.tsx b/src/components/Avatar/component.tsx new file mode 100644 index 0000000..fe2e161 --- /dev/null +++ b/src/components/Avatar/component.tsx @@ -0,0 +1,72 @@ +import React, { JSX, useEffect, useState } from 'react'; +import Tippy from '@tippyjs/react'; +import 'tippy.js/dist/tippy.css'; +import * as Styled from './styles'; +import { AvatarProps } from './types'; +import { DEFAULT_AVATAR_SIZE, AVATAR_FALLBACK_COLORS } from './constants'; +import { colorUserYou } from '../../stylesheets/palette'; + +function getInitials(name: string): string { + const words = name.trim().split(/\s+/).filter(Boolean); + if (words.length === 0) return ''; + if (words.length === 1) return words[0].slice(0, 2).toUpperCase(); + return `${words[0][0]}${words[words.length - 1][0]}`.toUpperCase(); +} + +function getFallbackColor(name: string): string { + const hash = name.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); + return AVATAR_FALLBACK_COLORS[hash % AVATAR_FALLBACK_COLORS.length]; +} + +/** + * A user avatar component. + * + * Renders the user's avatar image when available, falling back to their initials on a + * deterministically-colored background when no image is provided or it fails to load. + * + */ +function Avatar({ + name, + avatarUrl, + color, + size = DEFAULT_AVATAR_SIZE, + isModerator = false, + isYou = false, + isTalking = false, + disableTooltip = false, +}: AvatarProps): JSX.Element { + const [hasImageError, setHasImageError] = useState(false); + const resolvedColor = color || (isYou ? colorUserYou : getFallbackColor(name)); + + useEffect(() => { + setHasImageError(false); + }, [avatarUrl]); + + const avatarElement = avatarUrl && !hasImageError ? ( + setHasImageError(true)} + /> + ) : ( + + {getInitials(name)} + + ); + + if (disableTooltip) { + return avatarElement; + } + + return ( + + {avatarElement} + + ); +} + +export default Avatar; diff --git a/src/components/Avatar/constants.ts b/src/components/Avatar/constants.ts new file mode 100644 index 0000000..7e9e260 --- /dev/null +++ b/src/components/Avatar/constants.ts @@ -0,0 +1,15 @@ +export const AVATAR_SIZES = { + SMALL: 'small', + MEDIUM: 'medium', + LARGE: 'large', +} as const; + +export const AVATAR_SIZE_VALUES = Object.values(AVATAR_SIZES); +export const DEFAULT_AVATAR_SIZE = AVATAR_SIZES.MEDIUM; + +// Mirrors the palette akka-bbb-apps' ColorPicker assigns to users server-side (round-robin per +// meeting), so a name-hash fallback here lands on the same colors real BBB users get. +export const AVATAR_FALLBACK_COLORS = [ + '#7b1fa2', '#6a1b9a', '#4a148c', '#5e35b1', '#512da8', '#4527a0', '#311b92', + '#3949ab', '#303f9f', '#283593', '#1a237e', '#1976d2', '#1565c0', '#0d47a1', '#0277bd', '#01579b', +]; diff --git a/src/components/Avatar/index.ts b/src/components/Avatar/index.ts new file mode 100644 index 0000000..3d4170d --- /dev/null +++ b/src/components/Avatar/index.ts @@ -0,0 +1 @@ +export { default as BBBAvatar } from './component'; diff --git a/src/components/Avatar/styles.ts b/src/components/Avatar/styles.ts new file mode 100644 index 0000000..71ab5f7 --- /dev/null +++ b/src/components/Avatar/styles.ts @@ -0,0 +1,48 @@ +import styled, { css, keyframes } from 'styled-components'; +import { colorWhite } from '../../stylesheets/palette'; +import { AVATAR_SIZES } from './constants'; +import { StyledAvatarProps } from './types'; + +const DIMENSIONS = { + [AVATAR_SIZES.SMALL]: { dimension: '1.625rem', fontSize: '0.625rem', fontWeight: '600' }, + [AVATAR_SIZES.MEDIUM]: { dimension: '3rem', fontSize: '1.125rem', fontWeight: '500' }, + [AVATAR_SIZES.LARGE]: { dimension: '6rem', fontSize: '2.75rem', fontWeight: '400' }, +}; + +// Mirrors BBB's talking indicator (a ring, in the user's own color, that spreads and fades out). +const talkingPulse = (color: string) => keyframes` + 0% { box-shadow: 0 0 0 0 ${color}; } + 100% { box-shadow: 0 0 0 4px transparent; } +`; + +const talkingStyles = css` + ${({ $isTalking, $color }) => $isTalking && css` + animation: ${talkingPulse($color)} 1s infinite ease-in; + `} +`; + +export const AvatarInitials = styled.div` + width: ${({ $size }) => DIMENSIONS[$size].dimension}; + height: ${({ $size }) => DIMENSIONS[$size].dimension}; + border-radius: ${({ $isModerator }) => ($isModerator ? '20%' : '50%')}; + flex-shrink: 0; + background: ${({ $color }) => $color}; + display: flex; + align-items: center; + justify-content: center; + font-size: ${({ $size }) => DIMENSIONS[$size].fontSize}; + font-weight: ${({ $size }) => DIMENSIONS[$size].fontWeight}; + color: ${colorWhite}; + text-transform: uppercase; + ${talkingStyles} +`; + +export const AvatarImage = styled.img` + width: ${({ $size }) => DIMENSIONS[$size].dimension}; + height: ${({ $size }) => DIMENSIONS[$size].dimension}; + border-radius: ${({ $isModerator }) => ($isModerator ? '20%' : '50%')}; + flex-shrink: 0; + object-fit: cover; + border: 2px solid ${({ $color }) => $color}; + ${talkingStyles} +`; diff --git a/src/components/Avatar/types.ts b/src/components/Avatar/types.ts new file mode 100644 index 0000000..978b74f --- /dev/null +++ b/src/components/Avatar/types.ts @@ -0,0 +1,36 @@ +import { AVATAR_SIZE_VALUES } from './constants'; + +export type AvatarSize = typeof AVATAR_SIZE_VALUES[number]; + +export interface StyledAvatarProps { + $size: AvatarSize; + $color: string; + $isModerator: boolean; + $isTalking: boolean; +} + +export interface AvatarProps { + /** Full name of the user; used to render initials, to derive a deterministic fallback color, and as the tooltip content. */ + name: string; + + /** URL of the user's avatar image. Falls back to initials when omitted or if the image fails to load. */ + avatarUrl?: string; + + /** Background color behind the initials (and border color on the image). @default a color deterministically derived from `name`, or BBB's "you" color when `isYou` is set */ + color?: string; + + /** Size variant of the avatar. @default 'medium' */ + size?: AvatarSize; + + /** Renders a rounded-square shape instead of a circle, matching BBB's moderator avatar treatment. @default false */ + isModerator?: boolean; + + /** Marks this avatar as belonging to the current user, applying BBB's "you" color in place of the fallback/computed color. Ignored when `color` is set. @default false */ + isYou?: boolean; + + /** Shows a pulsing ring around the avatar, in its own color, matching BBB's talking indicator. @default false */ + isTalking?: boolean; + + /** Disables the tooltip that shows the full `name` on hover. @default false */ + disableTooltip?: boolean; +} diff --git a/src/components/index.ts b/src/components/index.ts index 230d706..ab4ba37 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,4 +1,5 @@ export { BBBAccordion } from './Accordion'; +export { BBBAvatar } from './Avatar'; export { BBButton } from './Button'; export { BBBCheckbox } from './Checkbox'; export { BBBDivider } from './Divider'; diff --git a/src/stylesheets/colors.ts b/src/stylesheets/colors.ts index 84251c7..fc5341a 100644 --- a/src/stylesheets/colors.ts +++ b/src/stylesheets/colors.ts @@ -9,6 +9,7 @@ import { colorHoverDark, colorHoverLight, colorHoverNeutral, colorOverlay, colorShadowDefault, + colorUserYou, } from './palette'; export const colors = { @@ -66,6 +67,9 @@ export const colors = { shadow: { default: colorShadowDefault, }, + user: { + you: colorUserYou, + }, } as const; export type Colors = typeof colors; diff --git a/src/stylesheets/palette.ts b/src/stylesheets/palette.ts index 1867278..6b43f8e 100644 --- a/src/stylesheets/palette.ts +++ b/src/stylesheets/palette.ts @@ -66,3 +66,6 @@ export const colorOverlay = 'var(--color-overlay, rgba(0, 0, 0, 0.75))'; //Shadow colors export const colorShadowDefault = 'var(--color-shadow-default, rgb(0 35 11 / 20%))'; + +// User colors +export const colorUserYou = 'var(--color-user-you, #19237C)'; diff --git a/webpack.config.babel.js b/webpack.config.babel.js index 461ae7f..90d5502 100644 --- a/webpack.config.babel.js +++ b/webpack.config.babel.js @@ -3,6 +3,7 @@ import path from 'path'; export default { entry: { Accordion: './src/components/Accordion/index.ts', + Avatar: './src/components/Avatar/index.ts', Button: './src/components/Button/index.ts', Checkbox: './src/components/Checkbox/index.ts', Divider: './src/components/Divider/index.ts',