diff --git a/packages/web-react/scripts/entryPoints.js b/packages/web-react/scripts/entryPoints.js index 4d2100dc28..5b6fd38449 100644 --- a/packages/web-react/scripts/entryPoints.js +++ b/packages/web-react/scripts/entryPoints.js @@ -45,6 +45,7 @@ const entryPoints = [ { dirs: ['components', 'TextFieldBase'] }, { dirs: ['components', 'Toast'] }, { dirs: ['components', 'Tooltip'] }, + { dirs: ['components', 'UNSTABLE_Avatar'] }, { dirs: ['components', 'UNSTABLE_Divider'] }, { dirs: ['components', 'UNSTABLE_Slider'] }, { dirs: ['components', 'VisuallyHidden'] }, diff --git a/packages/web-react/src/components/UNSTABLE_Avatar/README.md b/packages/web-react/src/components/UNSTABLE_Avatar/README.md new file mode 100644 index 0000000000..3f5063a630 --- /dev/null +++ b/packages/web-react/src/components/UNSTABLE_Avatar/README.md @@ -0,0 +1,114 @@ +# UNSTABLE Avatar + +⚠️ This component is UNSTABLE. It may significantly change at any point in the future. +Please use it with caution. + +The `UNSTABLE_Avatar` component is used to display a user's profile picture or initials. + +```jsx +import { UNSTABLE_Avatar } from '@lmc-eu/spirit-web-react'; + + + +; +``` + +## Square + +Add `isSquare` prop to make the avatar a square. + +```jsx + + + +``` + +## Sizes + +The Avatar component is available in all [extended sizes][dictionary-size]. + +```jsx + + + + + + + + + + + + + + + +``` + +## Content + +The content of the `UNSTABLE_Avatar` component can be an image, an icon, or a text string. + +### Icon + +Add an Icon with correct size. + +```jsx + + + + + + +``` + +ℹ️ Don't forget to add the `aria-label` attribute for accessible title. + +### Image + +Add an image, it will be resized to fit the avatar. + +```jsx + + + +``` + +ℹ️ Don't forget to add the `aria-label` attribute for accessible title. +The image should have an `alt` attribute set and can be aria-hidden, because the `aria-label` +attribute is set on the container. + +### Text + +It is possible to use text as the content of the `UNSTABLE_Avatar` component. +This is useful when you want to display the initials of a user. You need to +take care of the text length and case. The rest is handled by the component. + +```jsx + + + +``` + +ℹ️ Don't forget to add the `aria-label` attribute for accessible title, especially when +using an abbreviation. The `aria-hidden` attribute is set on the text span, because the `aria-label` +attribute is set on the container and the abbreviation is not useful for screen readers. + +## API + +| Name | Type | Default | Required | Description | +| ------------- | ------------------------------------------- | -------- | -------- | ------------------------- | +| `children` | `ReactNode` | `null` | ✓ | Content of the Avatar | +| `elementType` | `ElementType` | `button` | ✕ | Type of element | +| `isSquare` | `bool` | `false` | ✕ | If true, Avatar is square | +| `ref` | `ForwardedRef` | — | ✕ | Avatar element reference | +| `size` | [Size Extended dictionary][dictionary-size] | `medium` | ✕ | Size of the Avatar | + +The components accept [additional attributes][readme-additional-attributes]. +If you need more control over the styling of a component, you can use [style props][readme-style-props] +and [escape hatches][readme-escape-hatches]. + +[dictionary-size]: https://github.com/lmc-eu/spirit-design-system/tree/main/docs/DICTIONARIES.md#size +[readme-additional-attributes]: https://github.com/lmc-eu/spirit-design-system/blob/main/packages/web-react/README.md#additional-attributes +[readme-escape-hatches]: https://github.com/lmc-eu/spirit-design-system/blob/main/packages/web-react/README.md#escape-hatches +[readme-style-props]: https://github.com/lmc-eu/spirit-design-system/blob/main/packages/web-react/README.md#style-props diff --git a/packages/web-react/src/components/UNSTABLE_Avatar/UNSTABLE_Avatar.tsx b/packages/web-react/src/components/UNSTABLE_Avatar/UNSTABLE_Avatar.tsx new file mode 100644 index 0000000000..76af7ee98a --- /dev/null +++ b/packages/web-react/src/components/UNSTABLE_Avatar/UNSTABLE_Avatar.tsx @@ -0,0 +1,38 @@ +import classNames from 'classnames'; +import React, { ElementType, ForwardedRef, forwardRef } from 'react'; +import { SizesExtended } from '../../constants'; +import { useStyleProps } from '../../hooks'; +import { SpiritAvatarProps } from '../../types'; +import { useAvatarStyleProps } from './useAvatarStyleProps'; + +const defaultProps: Partial = { + elementType: 'div', + isSquare: false, + size: SizesExtended.MEDIUM, +}; + +/* We need an exception for components exported with forwardRef */ +/* eslint no-underscore-dangle: ['error', { allow: ['_Avatar'] }] */ +const _Avatar = ( + props: SpiritAvatarProps, + ref: ForwardedRef, +) => { + const propsWithDefaults = { ...defaultProps, ...props }; + const { + elementType: ElementTag = defaultProps.elementType as ElementType, + children, + ...restProps + } = propsWithDefaults; + const { classProps, props: modifiedProps } = useAvatarStyleProps(restProps); + const { styleProps, props: otherProps } = useStyleProps(modifiedProps); + + return ( + + {children} + + ); +}; + +export const UNSTABLE_Avatar = forwardRef>(_Avatar); + +export default UNSTABLE_Avatar; diff --git a/packages/web-react/src/components/UNSTABLE_Avatar/__tests__/UNSTABLE_Avatar.test.tsx b/packages/web-react/src/components/UNSTABLE_Avatar/__tests__/UNSTABLE_Avatar.test.tsx new file mode 100644 index 0000000000..5414b22270 --- /dev/null +++ b/packages/web-react/src/components/UNSTABLE_Avatar/__tests__/UNSTABLE_Avatar.test.tsx @@ -0,0 +1,84 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { classNamePrefixProviderTest } from '../../../../tests/providerTests/classNamePrefixProviderTest'; +import { sizeExtendedPropsTest } from '../../../../tests/providerTests/dictionaryPropsTest'; +import { restPropsTest } from '../../../../tests/providerTests/restPropsTest'; +import { stylePropsTest } from '../../../../tests/providerTests/stylePropsTest'; +import { Icon } from '../../Icon'; +import { UNSTABLE_Avatar } from '../UNSTABLE_Avatar'; + +describe('UNSTABLE_Avatar', () => { + classNamePrefixProviderTest(UNSTABLE_Avatar, 'UNSTABLE_Avatar'); + + sizeExtendedPropsTest(UNSTABLE_Avatar); + + stylePropsTest(UNSTABLE_Avatar); + + restPropsTest(UNSTABLE_Avatar, 'div'); + + it('should have default classname', () => { + render(JB); + + expect(screen.getByTitle('Jiří Bárta')).toHaveClass('UNSTABLE_Avatar'); + }); + + it('should have default classname with elementType', () => { + render( + + JB + , + ); + + expect(screen.getByTitle('Jiří Bárta')).toHaveClass('UNSTABLE_Avatar'); + expect(screen.getByTitle('Jiří Bárta')).toHaveAttribute('href', '#'); + }); + + it('should have square classname with isSquare', () => { + render( + + JB + , + ); + + expect(screen.getByTitle('Jiří Bárta')).toHaveClass('UNSTABLE_Avatar'); + expect(screen.getByTitle('Jiří Bárta')).toHaveClass('UNSTABLE_Avatar--square'); + }); + + it('should have size classname', () => { + render( + + JB + , + ); + + expect(screen.getByTitle('Jiří Bárta')).toHaveClass('UNSTABLE_Avatar'); + expect(screen.getByTitle('Jiří Bárta')).toHaveClass('UNSTABLE_Avatar--xsmall'); + }); + + it('should render Icon', () => { + render( + + + , + ); + + expect(screen.getByTitle('Jiří Bárta').querySelector('svg')).toBeInTheDocument(); + }); + + it('should render text children', () => { + render(JB); + + expect(screen.getByTitle('Jiří Bárta')).toHaveTextContent('JB'); + }); + + it('should render image', () => { + render( + + + , + ); + + expect(screen.getByTestId('test-image')).toBeInTheDocument(); + }); +}); diff --git a/packages/web-react/src/components/UNSTABLE_Avatar/__tests__/useAvatarStyleProps.test.ts b/packages/web-react/src/components/UNSTABLE_Avatar/__tests__/useAvatarStyleProps.test.ts new file mode 100644 index 0000000000..1129a60a13 --- /dev/null +++ b/packages/web-react/src/components/UNSTABLE_Avatar/__tests__/useAvatarStyleProps.test.ts @@ -0,0 +1,31 @@ +import { renderHook } from '@testing-library/react'; +import { SizesExtended } from '../../../constants/dictionaries'; +import { useAvatarStyleProps } from '../useAvatarStyleProps'; + +describe('useAvatarStyleProps', () => { + const defaultProps = { size: SizesExtended.MEDIUM }; // default size passed to the hook from the component + + it('should return defaults', () => { + const props = { ...defaultProps }; + const { result } = renderHook(() => useAvatarStyleProps(props)); + + expect(result.current.classProps).toBe('UNSTABLE_Avatar UNSTABLE_Avatar--medium'); + }); + + it('should return square avatar', () => { + const props = { + ...defaultProps, + isSquare: true, + }; + const { result } = renderHook(() => useAvatarStyleProps(props)); + + expect(result.current.classProps).toBe('UNSTABLE_Avatar UNSTABLE_Avatar--medium UNSTABLE_Avatar--square'); + }); + + it.each(Object.values(SizesExtended))('should return %s size avatar', (size) => { + const props = { size }; + const { result } = renderHook(() => useAvatarStyleProps(props)); + + expect(result.current.classProps).toBe(`UNSTABLE_Avatar UNSTABLE_Avatar--${size}`); + }); +}); diff --git a/packages/web-react/src/components/UNSTABLE_Avatar/constants.ts b/packages/web-react/src/components/UNSTABLE_Avatar/constants.ts new file mode 100644 index 0000000000..f68404af02 --- /dev/null +++ b/packages/web-react/src/components/UNSTABLE_Avatar/constants.ts @@ -0,0 +1,24 @@ +import { SizesExtended } from '../../constants'; + +export const DEMO_SIZES = [ + { + size: SizesExtended.XSMALL, + boxSize: 16, + }, + { + size: SizesExtended.SMALL, + boxSize: 20, + }, + { + size: SizesExtended.MEDIUM, + boxSize: 24, + }, + { + size: SizesExtended.LARGE, + boxSize: 28, + }, + { + size: SizesExtended.XLARGE, + boxSize: 32, + }, +]; diff --git a/packages/web-react/src/components/UNSTABLE_Avatar/demo/AvatarIcon.tsx b/packages/web-react/src/components/UNSTABLE_Avatar/demo/AvatarIcon.tsx new file mode 100644 index 0000000000..534a6a9c6c --- /dev/null +++ b/packages/web-react/src/components/UNSTABLE_Avatar/demo/AvatarIcon.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Icon } from '../../Icon'; +import { DEMO_SIZES } from '../constants'; +import { UNSTABLE_Avatar } from '../UNSTABLE_Avatar'; + +const AvatarIcon = () => ( + <> +
+ {DEMO_SIZES.map(({ size, boxSize }) => ( + + + + ))} +
+
+ {DEMO_SIZES.map(({ size, boxSize }) => ( + + + + ))} +
+ +); + +export default AvatarIcon; diff --git a/packages/web-react/src/components/UNSTABLE_Avatar/demo/AvatarImage.tsx b/packages/web-react/src/components/UNSTABLE_Avatar/demo/AvatarImage.tsx new file mode 100644 index 0000000000..a94b979aee --- /dev/null +++ b/packages/web-react/src/components/UNSTABLE_Avatar/demo/AvatarImage.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { DEMO_SIZES } from '../constants'; +import UNSTABLE_Avatar from '../UNSTABLE_Avatar'; + +const AvatarImage = () => ( + <> +
+ {DEMO_SIZES.map(({ size }) => ( + + + + ))} +
+
+ {DEMO_SIZES.map(({ size }) => ( + + + + ))} +
+ +); + +export default AvatarImage; diff --git a/packages/web-react/src/components/UNSTABLE_Avatar/demo/AvatarText.tsx b/packages/web-react/src/components/UNSTABLE_Avatar/demo/AvatarText.tsx new file mode 100644 index 0000000000..43f4e0859d --- /dev/null +++ b/packages/web-react/src/components/UNSTABLE_Avatar/demo/AvatarText.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { DEMO_SIZES } from '../constants'; +import { UNSTABLE_Avatar } from '../UNSTABLE_Avatar'; + +const AvatarText = () => ( + <> +
+ {DEMO_SIZES.map(({ size }) => ( + + + + ))} +
+
+ {DEMO_SIZES.map(({ size }) => ( + + + + ))} +
+ +); + +export default AvatarText; diff --git a/packages/web-react/src/components/UNSTABLE_Avatar/demo/index.tsx b/packages/web-react/src/components/UNSTABLE_Avatar/demo/index.tsx new file mode 100644 index 0000000000..f5d3c25775 --- /dev/null +++ b/packages/web-react/src/components/UNSTABLE_Avatar/demo/index.tsx @@ -0,0 +1,28 @@ +// Because there is no `dist` directory during the CI run +/* eslint-disable import/no-extraneous-dependencies, import/extensions, import/no-unresolved */ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment, import/extensions, import/no-unresolved +// @ts-ignore: No declaration file -- @see https://jira.almacareer.tech/browse/DS-561 +import icons from '@lmc-eu/spirit-icons/icons'; +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import DocsSection from '../../../../docs/DocsSections'; +import { IconsProvider } from '../../../context'; +import AvatarIcon from './AvatarIcon'; +import AvatarImage from './AvatarImage'; +import AvatarText from './AvatarText'; + +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + + + + + + + + + + + + + , +); diff --git a/packages/web-react/src/components/UNSTABLE_Avatar/index.html b/packages/web-react/src/components/UNSTABLE_Avatar/index.html new file mode 100644 index 0000000000..23972ef557 --- /dev/null +++ b/packages/web-react/src/components/UNSTABLE_Avatar/index.html @@ -0,0 +1 @@ +{{> web-react/demo}} diff --git a/packages/web-react/src/components/UNSTABLE_Avatar/index.ts b/packages/web-react/src/components/UNSTABLE_Avatar/index.ts new file mode 100644 index 0000000000..3be542d254 --- /dev/null +++ b/packages/web-react/src/components/UNSTABLE_Avatar/index.ts @@ -0,0 +1,3 @@ +export * from './UNSTABLE_Avatar'; +export * from './useAvatarStyleProps'; +export { default as UNSTABLE_Avatar } from './UNSTABLE_Avatar'; diff --git a/packages/web-react/src/components/UNSTABLE_Avatar/stories/UNSTABLE_Avatar.stories.tsx b/packages/web-react/src/components/UNSTABLE_Avatar/stories/UNSTABLE_Avatar.stories.tsx new file mode 100644 index 0000000000..4e819858c2 --- /dev/null +++ b/packages/web-react/src/components/UNSTABLE_Avatar/stories/UNSTABLE_Avatar.stories.tsx @@ -0,0 +1,58 @@ +import { Markdown } from '@storybook/blocks'; +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; +import { Sizes, SizesExtended } from '../../../constants'; +import { Icon } from '../../Icon'; +import ReadMe from '../README.md'; +import { UNSTABLE_Avatar } from '..'; + +const meta: Meta = { + title: 'Experimental/UNSTABLE_Avatar', + component: UNSTABLE_Avatar, + parameters: { + docs: { + page: () => {ReadMe}, + }, + }, + argTypes: { + children: { + control: 'select', + options: ['icon', 'image', 'text'], + description: `This is the place for the content of the Avatar. In the real code + you can pass in any children you want. In this demo we have predefined options: + \`icon\`, \`image\` and \`text\`. Please note the predefined options + in this demo are not customizable.`, + mapping: { + icon: , + image: , + text: , + }, + }, + elementType: { + control: 'text', + table: { + defaultValue: { summary: 'div' }, + }, + }, + isSquare: { + control: 'boolean', + }, + size: { + control: 'select', + options: [...Object.values(SizesExtended)], + }, + }, + args: { + children: 'text', + elementType: 'div', + isSquare: false, + size: Sizes.MEDIUM, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Playground: Story = { + name: 'UNSTABLE_Avatar', +}; diff --git a/packages/web-react/src/components/UNSTABLE_Avatar/useAvatarStyleProps.ts b/packages/web-react/src/components/UNSTABLE_Avatar/useAvatarStyleProps.ts new file mode 100644 index 0000000000..f6a5a11226 --- /dev/null +++ b/packages/web-react/src/components/UNSTABLE_Avatar/useAvatarStyleProps.ts @@ -0,0 +1,33 @@ +import classNames from 'classnames'; +import { ElementType } from 'react'; +import { useClassNamePrefix } from '../../hooks'; +import { AvatarSize, SpiritAvatarProps } from '../../types'; +import { applySize, compose } from '../../utils'; + +export interface AvatarStyles { + /** className props */ + classProps: string; + /** props to be passed to the element */ + props: SpiritAvatarProps; +} + +const getAvatarSizeClassname = (className: string, size: AvatarSize): string => + compose(applySize>(size))(className); + +export function useAvatarStyleProps( + props: SpiritAvatarProps, +): AvatarStyles { + const { isSquare, size, ...restProps } = props; + + const avatarClass = useClassNamePrefix('UNSTABLE_Avatar'); + const avatarSquareClass = `${avatarClass}--square`; + + const classProps = classNames(avatarClass, getAvatarSizeClassname(avatarClass, size as AvatarSize), { + [avatarSquareClass]: isSquare, + }); + + return { + classProps, + props: restProps, + }; +} diff --git a/packages/web-react/src/components/index.ts b/packages/web-react/src/components/index.ts index d4a800d73a..f62a29c8b7 100644 --- a/packages/web-react/src/components/index.ts +++ b/packages/web-react/src/components/index.ts @@ -33,5 +33,6 @@ export * from './TextField'; export * from './TextFieldBase'; export * from './Toast'; export * from './Tooltip'; +export * from './UNSTABLE_Avatar'; export * from './UNSTABLE_Divider'; export * from './VisuallyHidden'; diff --git a/packages/web-react/src/types/avatar.ts b/packages/web-react/src/types/avatar.ts new file mode 100644 index 0000000000..39a03de10b --- /dev/null +++ b/packages/web-react/src/types/avatar.ts @@ -0,0 +1,24 @@ +import { ElementType, JSXElementConstructor } from 'react'; +import { ChildrenProps, SizeExtendedDictionaryType, StyleProps, TransferProps } from './shared'; + +export type AvatarSize = SizeExtendedDictionaryType | S; + +export interface AriaAvatarElementTypeProps { + /** + * The HTML element or React element used to render the Avatar, e.g. 'div', 'span'. + * + * @default 'div' + */ + elementType?: E | JSXElementConstructor; +} + +export interface AvatarProps extends ChildrenProps, StyleProps, TransferProps {} + +export interface SpiritAvatarProps + extends AriaAvatarElementTypeProps, + AvatarProps { + /** Whether the Avatar should be square. */ + isSquare?: boolean; + /** Size of the Avatar */ + size?: AvatarSize; +} diff --git a/packages/web-react/src/types/index.ts b/packages/web-react/src/types/index.ts index 4e74a18dce..ba69f0b3d2 100644 --- a/packages/web-react/src/types/index.ts +++ b/packages/web-react/src/types/index.ts @@ -1,5 +1,6 @@ export * from './accordion'; export * from './alert'; +export * from './avatar'; export * from './breadcrumbs'; export * from './button'; export * from './checkbox'; diff --git a/packages/web/src/scss/components/UNSTABLE_Avatar/README.md b/packages/web/src/scss/components/UNSTABLE_Avatar/README.md index e978b8d0c5..74ed9840a3 100644 --- a/packages/web/src/scss/components/UNSTABLE_Avatar/README.md +++ b/packages/web/src/scss/components/UNSTABLE_Avatar/README.md @@ -32,7 +32,8 @@ Add `UNSTABLE_Avatar--square` modifier to make the avatar a square. ## Sizes -The Avatar component supports `xsmall`, `small`, `medium`, `large`, and `xlarge` sizes. +The Avatar component is available in all [extended sizes][dictionary-size]. +Use the `UNSTABLE_Avatar--` modifier class to change the size of the Avatar component. ```html
@@ -99,3 +100,5 @@ take care of the text length and case. The rest is handled by the component. ℹ️ Don't forget to add the `aria-label` attribute for accessible title, especially when using an abbreviation. The `aria-hidden` attribute is set on the text span, because the `aria-label` attribute is set on the container and the abbreviation is not useful for screen readers. + +[dictionary-size]: https://github.com/lmc-eu/spirit-design-system/blob/main/docs/DICTIONARIES.md#size