diff --git a/packages/components/avatar/index.ts b/packages/components/avatar/index.ts new file mode 100644 index 00000000..8e96fb3c --- /dev/null +++ b/packages/components/avatar/index.ts @@ -0,0 +1,8 @@ +import { withInstall } from '@puik/utils' + +import Avatar from './src/avatar.vue' + +export const PuikAvatar = withInstall(Avatar) +export default PuikAvatar + +export * from './src/avatar' diff --git a/packages/components/avatar/src/avatar.ts b/packages/components/avatar/src/avatar.ts new file mode 100644 index 00000000..454d9bf9 --- /dev/null +++ b/packages/components/avatar/src/avatar.ts @@ -0,0 +1,93 @@ +import { buildProps } from '@puik/utils' +import type { ExtractPropTypes, PropType } from 'vue' +import type Avatar from './avatar.vue' + +export enum PuikAvatarMode { + PRIMARY = 'primary', + REVERSE = 'reverse', +} +export const AVATAR_MODE = { + primary: 'white', + reverse: 'black', +} + +export enum PuikAvatarSize { + SMALL = 'small', + MEDIUM = 'medium', + LARGE = 'large', + JUMBO = 'jumbo', +} +export const ICONS_FONTSIZE = { + small: '1rem', + medium: '1.5rem', + large: '2rem', + jumbo: '2.8rem', +} + +export enum PuikAvatarType { + PHOTO = 'photo', + ICON = 'icon', + INITIALS = 'initials', +} + +export const avatarProps = buildProps({ + id: { + type: String, + required: false, + default: undefined, + }, + mode: { + type: String as PropType, + required: false, + default: PuikAvatarMode.PRIMARY, + }, + size: { + type: String as PropType, + required: false, + default: PuikAvatarSize.MEDIUM, + }, + type: { + type: String as PropType, + required: false, + default: PuikAvatarType.INITIALS, + }, + icon: { + type: String, + required: false, + default: '', + }, + src: { + type: String, + required: false, + default: '', + }, + alt: { + type: String, + required: false, + default: '', + }, + firstname: { + type: String, + required: false, + default: '', + }, + lastname: { + type: String, + required: false, + default: '', + }, + singleInitial: { + type: Boolean, + required: false, + default: false, + }, + dataTest: { + type: String, + required: false, + default: undefined, + }, +} as const) + +export type AvatarProps = ExtractPropTypes + +export type AvatarInstance = InstanceType diff --git a/packages/components/avatar/src/avatar.vue b/packages/components/avatar/src/avatar.vue new file mode 100644 index 00000000..5cb22e5f --- /dev/null +++ b/packages/components/avatar/src/avatar.vue @@ -0,0 +1,66 @@ + + + diff --git a/packages/components/avatar/stories/avatar.stories.ts b/packages/components/avatar/stories/avatar.stories.ts new file mode 100644 index 00000000..07b4312c --- /dev/null +++ b/packages/components/avatar/stories/avatar.stories.ts @@ -0,0 +1,506 @@ +import { ref } from 'vue' +import { PuikIcon } from '@puik/components' +import { PuikAvatarMode, PuikAvatarSize, PuikAvatarType } from '../src/avatar' +import PuikAvatar from './../src/avatar.vue' +import type { Meta, StoryFn, Args } from '@storybook/vue3' + +const avatarModes = Object.values(PuikAvatarMode) +const avatarSizes = Object.values(PuikAvatarSize) +const avatarTypes = Object.values(PuikAvatarType) +const avatarModesSummary = avatarModes.join('|') +const avatarSizesSummary = avatarSizes.join('|') +const avatarTypesSummary = avatarTypes.join('|') + +export default { + title: 'Components/Avatar', + component: PuikAvatar, + argTypes: { + id: { + description: + "Prop which will correspond to the component's html id attribute. NB: must not start with a number", + control: 'text', + table: { + type: { + summary: 'string', + }, + defaultValue: { + summary: 'undefined', + }, + }, + }, + mode: { + description: + 'Two possible variations (primary and reverse) : depending on a dark or light background of the container where the avatar is placed', + control: 'select', + options: avatarModes, + table: { + type: { + summary: avatarModesSummary, + }, + defaultValue: { + summary: 'primary', + }, + }, + }, + size: { + description: + 'Size variants of avatar component (small, medium, large, jumbo)', + control: 'select', + options: avatarSizes, + table: { + type: { + summary: avatarSizesSummary, + }, + defaultValue: { + summary: 'medium', + }, + }, + }, + type: { + description: 'Content type of avatar (initials, image or icon)', + control: 'select', + options: avatarTypes, + table: { + type: { + summary: avatarTypesSummary, + }, + defaultValue: { + summary: 'initials', + }, + }, + }, + icon: { + description: 'Material icon name (cf https://fonts.google.com/icons)', + control: 'text', + table: { + type: { + summary: 'string', + }, + defaultValue: { + summary: 'none', + }, + }, + }, + src: { + description: 'Image source if avatar type is set to "photo"', + control: 'text', + table: { + type: { + summary: 'string', + }, + defaultValue: { + summary: 'none', + }, + }, + }, + alt: { + description: 'Image alt attribute if avatar type prop is set to "photo"', + control: 'text', + table: { + type: { + summary: 'string', + }, + defaultValue: { + summary: 'none', + }, + }, + }, + firstname: { + description: + 'If avatar type prop is set to "initials". The "initials" type is composed of two letters max (first letter of firstname prop corresponds to the first). NB: if the lastname prop is missing then the initials will be the first two letters of the firstname prop in the case where the singleInitial prop is false. Special characters are removed', + control: 'text', + table: { + type: { + summary: 'string', + }, + defaultValue: { + summary: 'none', + }, + }, + }, + lastname: { + description: + 'If avatar type prop is set to "initials". The "initials" type is composed of two letters max (first letter of lastname corresponds to the last). NB : if the firstname prop is missing then the initials will be the first two letters of the lastname prop in the case where the singleInitial prop is false. Special characters are removed.', + control: 'text', + table: { + type: { + summary: 'string', + }, + defaultValue: { + summary: 'none', + }, + }, + }, + singleInitial: { + description: + 'Initials match a single letter (first letter of firstname. If the firstname conditions are not met this is the first letter of lastname). NB: if the conditions for the firstname and lastname props are not met then the default value is "P" (singleInitial set to true) or "PS" (singleInitial set to false).', + table: { + defaultValue: { + summary: false, + }, + }, + }, + dataTest: { + control: 'text', + description: + 'Set the data-test attribute for the avatar `image-${dataTest}` `icon-${dataTest}` `initials-${dataTest}`', + }, + }, + args: { + id: 'puik-avatar-id', + mode: 'primary', + size: 'medium', + type: 'initials', + icon: 'home', + src: 'https://picsum.photos/200', + alt: 'puik-avatar-alt', + firstname: 'Presta', + lastname: 'Shop', + singleInitial: false, + dataTest: undefined, + }, +} as Meta + +const DefaultTemplate: StoryFn = (args: Args) => ({ + components: { + PuikIcon, + PuikAvatar, + }, + setup() { + return { args } + }, + template: ` + +`, +}) + +const TypesTemplate: StoryFn = (args: Args) => ({ + components: { + PuikIcon, + PuikAvatar, + }, + setup() { + return { args } + }, + template: ` +
+ + + +
+`, +}) + +const SizesTemplate: StoryFn = (args: Args) => ({ + components: { + PuikIcon, + PuikAvatar, + }, + setup() { + const avatars = ref([ + { + type: 'initials', + size: 'small', + }, + { + type: 'initials', + size: 'medium', + }, + { + type: 'initials', + size: 'large', + }, + { + type: 'initials', + size: 'jumbo', + }, + { + type: 'icon', + size: 'small', + icon: 'home', + }, + { + type: 'icon', + size: 'medium', + icon: 'home', + }, + { + type: 'icon', + size: 'large', + icon: 'home', + }, + { + type: 'icon', + size: 'jumbo', + icon: 'home', + }, + { + type: 'photo', + size: 'small', + src: 'https://picsum.photos/200', + }, + { + type: 'photo', + size: 'medium', + src: 'https://picsum.photos/200', + }, + { + type: 'photo', + size: 'large', + src: 'https://picsum.photos/200', + }, + { + type: 'photo', + size: 'jumbo', + src: 'https://picsum.photos/200', + }, + ]) + return { avatars, args } + }, + template: ` +
+ +
+`, +}) + +const ModesTemplate: StoryFn = (args: Args) => ({ + components: { + PuikIcon, + PuikAvatar, + }, + setup() { + const avatarsPrimary = ref([ + { + type: 'initials', + mode: 'primary', + }, + { + type: 'icon', + mode: 'primary', + icon: 'home', + }, + ]) + + const avatarsReverse = ref([ + { + type: 'initials', + mode: 'reverse', + }, + { + type: 'icon', + mode: 'reverse', + icon: 'home', + }, + ]) + + return { avatarsPrimary, avatarsReverse, args } + }, + template: ` +
+ +
+
+ +
+`, +}) + +export const Default = { + render: DefaultTemplate, + args: {}, + parameters: { + docs: { + source: { + code: ` + + + + + + +
+
+ PS +
+
+ `, + language: 'html', + }, + }, + }, +} + +export const Types = { + render: TypesTemplate, + args: {}, + parameters: { + docs: { + source: { + code: ` + + + + + + + + + + +
+
+ PS +
+
+ +
+
+ home +
+
+ +
+ puik-avatar-alt +
+ `, + language: 'html', + }, + }, + }, +} + +export const Sizes = { + render: SizesTemplate, + args: {}, + parameters: { + docs: { + source: { + code: ` + + + + + + + + +
+
+ PS +
+
+ + +
+
+ home +
+
+ +
+ puik-avatar-alt +
+`, + language: 'html', + }, + }, + }, +} + +export const Modes = { + render: ModesTemplate, + args: {}, + parameters: { + docs: { + source: { + code: ` + + + + + + + +
+
+ PS +
+
+ + +
+
+ home +
+
+`, + language: 'html', + }, + }, + }, +} diff --git a/packages/components/avatar/style/css.ts b/packages/components/avatar/style/css.ts new file mode 100644 index 00000000..baf5bacb --- /dev/null +++ b/packages/components/avatar/style/css.ts @@ -0,0 +1,2 @@ +import '@puik/components/base/style/css' +import '@puik/theme/puik-avatar.css' diff --git a/packages/components/avatar/style/index.ts b/packages/components/avatar/style/index.ts new file mode 100644 index 00000000..22f633c6 --- /dev/null +++ b/packages/components/avatar/style/index.ts @@ -0,0 +1,2 @@ +import '@puik/components/base/style' +import '@puik/theme/src/avatar.scss' diff --git a/packages/components/avatar/test/avatar.spec.ts b/packages/components/avatar/test/avatar.spec.ts new file mode 100644 index 00000000..761bf94f --- /dev/null +++ b/packages/components/avatar/test/avatar.spec.ts @@ -0,0 +1,88 @@ +import { mount } from '@vue/test-utils' +import { describe, it, expect } from 'vitest' +import PuikAvatar from '../src/avatar.vue' +import type { MountingOptions, VueWrapper } from '@vue/test-utils' + +describe('Avatar tests', () => { + let wrapper: VueWrapper + const findAvatar = () => wrapper.find('.puik-avatar') + const findInitialsWrapper = () => wrapper.find('.puik-avatar_initials') + const findIcon = () => wrapper.find('.puik-icon') + const findImg = () => wrapper.find('.puik-avatar img') + + const factory = ( + propsData: Record = {}, + options: MountingOptions = {} + ) => { + wrapper = mount(PuikAvatar, { + props: { + ...propsData, + }, + ...options, + }) + } + it('should be a vue instance', () => { + factory() + expect(wrapper).toBeTruthy() + }) + + it('as id prop value is "puik-avatar-id", id html attribute of puik-avatar should be "puik-avatar-id"', () => { + factory({ id: 'puik-avatar-id' }) + expect(findAvatar().attributes().id).toBe('puik-avatar-id') + }) + + it('should display the avatar in reverse mode', () => { + factory({ mode: 'reverse' }) + expect(findAvatar().classes()).toContain('puik-avatar--reverse') + }) + + it('should display the avatar in large size', () => { + factory({ size: 'large' }) + expect(findAvatar().classes()).toContain('puik-avatar--large') + }) + + it('should display the initials "PA"', () => { + factory({ firstname: 'Puik', lastname: 'Avatar' }) + expect(findInitialsWrapper().text()).toBe('PA') + }) + + it('icon type avatar should display the icon material "home"', () => { + factory({ type: 'icon', icon: 'home' }) + expect(findIcon().text()).toBe('home') + }) + + it('photo type avatar should display an image with src attribute set to "src-img" and an attribute alt set to "alt-img"', () => { + factory({ type: 'photo', src: 'src-img', alt: 'alt-img' }) + expect(findImg().attributes().src).toBe('src-img') + expect(findImg().attributes().alt).toBe('alt-img') + }) + + it('should have data-test attribute on initials wrapper', () => { + factory({ + type: 'initials', + dataTest: 'example', + }) + expect(findInitialsWrapper().attributes('data-test')).toBe( + 'initials-example' + ) + }) + + it('should have data-test attribute on icon', () => { + factory({ + type: 'icon', + icon: 'home', + dataTest: 'example', + }) + expect(findIcon().attributes('data-test')).toBe('icon-example') + }) + + it('should have data-test attribute on image', () => { + factory({ + type: 'photo', + src: 'src-example', + alt: 'alt-example', + dataTest: 'example', + }) + expect(findImg().attributes('data-test')).toBe('image-example') + }) +}) diff --git a/packages/components/icon/src/icon.vue b/packages/components/icon/src/icon.vue index 6071cd73..b758ed5d 100644 --- a/packages/components/icon/src/icon.vue +++ b/packages/components/icon/src/icon.vue @@ -22,12 +22,13 @@ const fontSize = computed(() => { if (!Number.isNaN(Number(props.fontSize))) { return `${props.fontSize}px` } - return props.fontSize }) -const style = { - fontSize: fontSize.value, - color: props.color, -} +const style = computed(() => { + return { + fontSize: fontSize.value, + color: props.color, + } +}) diff --git a/packages/components/index.ts b/packages/components/index.ts index 54dbcd54..c04dd16e 100644 --- a/packages/components/index.ts +++ b/packages/components/index.ts @@ -29,4 +29,5 @@ export * from './tab-navigation' export * from './progress-stepper' export * from './chip' export * from './tag' +export * from './avatar' export * from './divider' diff --git a/packages/puik/component.ts b/packages/puik/component.ts index c79653ca..53b2059e 100644 --- a/packages/puik/component.ts +++ b/packages/puik/component.ts @@ -1,3 +1,4 @@ +import { PuikAvatar } from '@puik/components/avatar' import { PuikDivider } from '@puik/components/divider' import { PuikTag } from '@puik/components/tag' import { PuikChip } from '@puik/components/chip' @@ -51,6 +52,7 @@ import type { Plugin } from 'vue' // prettier-ignore export default [ + PuikAvatar, PuikDivider, PuikTag, PuikChip, diff --git a/packages/theme/src/avatar.scss b/packages/theme/src/avatar.scss new file mode 100644 index 00000000..e84b2b7a --- /dev/null +++ b/packages/theme/src/avatar.scss @@ -0,0 +1,53 @@ +@use './common/typography.scss'; + +.puik-avatar { + @apply flex justify-center items-center bg-primary-800 rounded-full overflow-hidden; +} +.puik-avatar--small { + @apply w-6 h-6; +} +.puik-avatar--medium { + @apply w-8 h-8; +} +.puik-avatar--large { + @apply w-12 h-12; +} +.puik-avatar--jumbo { + @apply w-14 h-14; +} +.puik-avatar--primary { + @apply border border-transparent; + .puik-avatar_initials { + @apply text-white; + } +} +.puik-avatar--reverse { + @apply border border-primary-300; + .puik-avatar_initials { + @apply text-primary-800; + } +} +.puik-avatar--photo { + @apply border-none; + img { + @apply object-cover; + } +} +.puik-avatar--reverse.puik-avatar { + @apply bg-white; +} +.puik-avatar_initials { + @apply font-bold; + &--small { + @apply text-xs/none; + } + &--medium { + @apply text-sm/none; + } + &--large { + @apply text-base/none; + } + &--jumbo { + @apply text-2xl/none; + } +} diff --git a/packages/theme/src/index.scss b/packages/theme/src/index.scss index 35ddfef1..43da5834 100644 --- a/packages/theme/src/index.scss +++ b/packages/theme/src/index.scss @@ -42,4 +42,5 @@ @use 'progress-stepper-step'; @use 'chip'; @use 'tag'; +@use 'avatar'; @use 'divider'; diff --git a/packages/utils/getInitialLetter.ts b/packages/utils/getInitialLetter.ts new file mode 100644 index 00000000..3a8a5f29 --- /dev/null +++ b/packages/utils/getInitialLetter.ts @@ -0,0 +1,13 @@ +/** + * Retrieves a letter from a string. + * + * @param {string} str - The string to process (with removal of special characters + uppercase process). + * @param {number} index - The index of the letter to retrieve. + * @returns {string} The letter at the specified index. + */ +export const getInitialLetter = (str: string, index: number): string => { + return str + .replace(/[^a-zA-Z0-9]/g, '') + .charAt(index) + .toUpperCase() +} diff --git a/packages/utils/index.ts b/packages/utils/index.ts index 20ea01d9..6a9c57dc 100644 --- a/packages/utils/index.ts +++ b/packages/utils/index.ts @@ -7,3 +7,4 @@ export * from './types' export * from './typescript' export * from './isEllipsisActive' export * from './clamp' +export * from './getInitialLetter' diff --git a/typings/global.d.ts b/typings/global.d.ts index e188f5ce..6dce5872 100644 --- a/typings/global.d.ts +++ b/typings/global.d.ts @@ -1,6 +1,7 @@ // GlobalComponents for Volar declare module '@vue/runtime-core' { export interface GlobalComponents { + PuikAvatar: typeof import('@prestashopcorp/puik')['PuikAvatar'] PuikDivider: typeof import('@prestashopcorp/puik')['PuikDivider'] PuikTag: typeof import('@prestashopcorp/puik')['PuikTag'] PuikTabNavigationGroupPanels: typeof import('@prestashopcorp/puik')['PuikTabNavigationGroupPanels']