Skip to content

feat: add NeAvatar component #91

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remember to remove the pack pushes

Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@nethesis/vue-components",
"type": "module",
"version": "3.0.1",
"version": "3.0.6",
"private": false,
"keywords": [
"nethserver",
Expand Down
Binary file added src/assets/avatar.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
100 changes: 100 additions & 0 deletions src/components/NeAvatar.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<!--
Copyright (C) 2025 Nethesis S.r.l.
SPDX-License-Identifier: GPL-3.0-or-later
-->
<script lang="ts" setup>
import { faUser } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { computed, ref, useSlots } from 'vue'

export type AvatarSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl'

const slots = useSlots()

const {
size = 'md',
img = '',
initials = '',
squared = false,
alt = 'Avatar'
} = defineProps<{
size?: AvatarSize
img?: string
initials?: string
squared?: boolean
alt?: string
}>()

const avatarSizeClasses: Record<AvatarSize, string> = {
xs: 'size-6',
sm: 'size-8',
md: 'size-10',
lg: 'size-12',
xl: 'size-14',
'2xl': 'size-16',
'3xl': 'size-20',
'4xl': 'size-24'
}

const placeholderColorClasses = 'bg-gray-700 text-white dark:bg-gray-200 dark:text-gray-950'

const placeholderIconSizeClasses: Record<AvatarSize, string> = {
xs: 'size-3',
sm: 'size-4',
md: 'size-5',
lg: 'size-6',
xl: 'size-7',
'2xl': 'size-8',
'3xl': 'size-10',
'4xl': 'size-12'
}

const initialsSizeClasses: Record<AvatarSize, string> = {
xs: 'text-xs',
sm: 'text-sm',
md: 'text-base',
lg: 'text-xl',
xl: 'text-2xl',
'2xl': 'text-3xl',
'3xl': 'text-4xl',
'4xl': 'text-5xl'
}

const imageError = ref(false)

const hasPlaceholder = computed(() => slots.placeholder)

const placeholderContainerClasses = computed(
() =>
`flex items-center justify-center ${placeholderColorClasses} ${squared ? 'rounded-sm' : 'rounded-full'} ${avatarSizeClasses[size]}`
)

function setImageError() {
imageError.value = true
}
</script>
<template>
<div>
<img
v-if="img && !imageError"
:alt="alt"
:class="`${avatarSizeClasses[size]} ${squared ? 'rounded-sm' : 'rounded-full'}`"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
:class="`${avatarSizeClasses[size]} ${squared ? 'rounded-sm' : 'rounded-full'}`"
:class="[avatarSizeClasses[size], squared ? 'rounded-sm' : 'rounded-full']"

:src="img"
@error="setImageError"
/>
<div v-else-if="!initials && hasPlaceholder">
<slot name="placeholder" />
</div>
<div v-else-if="(imageError || !img) && !initials" :class="placeholderContainerClasses">
<FontAwesomeIcon
:icon="faUser"
:class="`${placeholderColorClasses} ${placeholderIconSizeClasses[size]}`"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
:class="`${placeholderColorClasses} ${placeholderIconSizeClasses[size]}`"
:class="[placeholderColorClasses, placeholderIconSizeClasses[size]]"

/>
</div>
<div v-else :class="`${placeholderContainerClasses} font-medium`">
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<div v-else :class="`${placeholderContainerClasses} font-medium`">
<div v-else :class="[placeholderContainerClasses, 'font-medium']">

<div :class="initialsSizeClasses[size]">
{{ initials }}
</div>
</div>
</div>
</template>
17 changes: 12 additions & 5 deletions src/components/NeCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import NeSkeleton from './NeSkeleton.vue'
import NeInlineNotification from './NeInlineNotification.vue'
import NeDropdown, { type NeDropdownItem } from './NeDropdown.vue'
import { computed, useSlots } from 'vue'

const props = defineProps({
title: {
Expand Down Expand Up @@ -46,7 +47,13 @@ const props = defineProps({
}
})

defineEmits(['titleClick'])
const slots = useSlots()

const isHeaderShown = computed(() => {
return (
props.title || slots.title || props.icon?.length || slots.topRight || props.menuItems?.length
)
})
</script>

<template>
Expand All @@ -57,12 +64,12 @@ defineEmits(['titleClick'])
]"
>
<!-- header -->
<div class="flex justify-between">
<div v-if="isHeaderShown" class="flex justify-between">
<!-- title -->
<div class="mb-3 flex items-center gap-1">
<h3
v-if="title || $slots.title"
class="leading-6 font-semibold text-gray-900 dark:text-gray-50"
class="leading-6 font-medium text-gray-900 dark:text-gray-50"
>
<span v-if="title">
{{ title }}
Expand Down Expand Up @@ -94,8 +101,8 @@ defineEmits(['titleClick'])
</div>
</div>
<!-- description and content -->
<div class="flex flex-row items-center justify-between">
<div class="grow">
<div class="flex h-full flex-row items-center justify-between">
<div class="h-full grow">
<NeSkeleton v-if="loading" :lines="skeletonLines"></NeSkeleton>
<NeInlineNotification
v-else-if="errorTitle"
Expand Down
1 change: 1 addition & 0 deletions src/components/NeDropdown.vue
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ watch(
]"
:class="`absolute z-50 mt-2.5 min-w-40 rounded-md bg-white py-2 shadow-lg ring-1 ring-gray-200 focus:outline-hidden dark:bg-gray-950 dark:ring-gray-700 ${menuClasses}`"
>
<slot name="menuHeader"></slot>
<template v-for="item in items" :key="item.id">
<!-- divider -->
<hr
Expand Down
2 changes: 1 addition & 1 deletion src/components/NeModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ function onSecondaryClick() {
<DialogTitle
v-if="title"
as="h3"
class="mb-4 text-base leading-6 font-semibold text-gray-900 dark:text-gray-50"
class="mb-4 text-base leading-6 font-medium text-gray-900 dark:text-gray-50"
>{{ title }}</DialogTitle
>
<div>
Expand Down
2 changes: 2 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export { default as NeHeading } from './components/NeHeading.vue'
export { default as NeListbox } from './components/NeListbox.vue'
export { default as NeDropdownFilter } from './components/NeDropdownFilter.vue'
export { default as NeSortDropdown } from './components/NeSortDropdown.vue'
export { default as NeAvatar } from './components/NeAvatar.vue'

// types export
export type { NeComboboxOption } from './components/NeCombobox.vue'
Expand All @@ -49,6 +50,7 @@ export type { FilterOption, FilterKind } from './components/NeDropdownFilter.vue
export type { RadioOption } from './components/NeRadioSelection.vue'
export type { SortEvent } from './components/NeTableHeadCell.vue'
export type { ModalKind, PrimaryButtonKind, ModalSize } from './components/NeModal.vue'
export type { AvatarSize } from './components/NeAvatar.vue'

// library functions export
export {
Expand Down
92 changes: 92 additions & 0 deletions stories/NeAvatar.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Copyright (C) 2025 Nethesis S.r.l.
// SPDX-License-Identifier: GPL-3.0-or-later

import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { NeAvatar } from '../src/main'
import AvatarImage from '../src/assets/avatar.png'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { faUserSecret } from '@fortawesome/free-solid-svg-icons'

const meta = {
title: 'NeAvatar',
component: NeAvatar,
tags: ['autodocs'],
argTypes: {
size: { control: 'inline-radio', options: ['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl'] }
},
args: {
size: 'md',
img: '',
initials: '',
squared: false,
alt: 'Avatar'
} // default values
} satisfies Meta<typeof NeAvatar>

export default meta
type Story = StoryObj<typeof meta>

const defaultTemplate = `<NeAvatar v-bind="args" />`

export const Default: Story = {
render: (args) => ({
components: { NeAvatar },
setup() {
return { args }
},
template: defaultTemplate
}),
args: {}
}

export const Squared: Story = {
render: (args) => ({
components: { NeAvatar },
setup() {
return { args }
},
template: defaultTemplate
}),
args: { squared: true }
}

export const Image: Story = {
render: (args) => ({
components: { NeAvatar },
setup() {
return { args }
},
template: defaultTemplate
}),
args: { img: AvatarImage, alt: 'User avatar' }
}

export const Initials: Story = {
render: (args) => ({
components: { NeAvatar },
setup() {
return { args }
},
template: defaultTemplate
}),
args: { initials: 'JD' }
}

const placeholderSlotTemplate = `<NeAvatar v-bind="args">
<template #placeholder>
<div class="flex items-center justify-center size-16 rounded-full bg-emerald-500 text-emerald-950 dark:bg-emerald-200 dark:text-emerald-950">
<FontAwesomeIcon :icon="faUserSecret" class="size-8" />
</div>
</template>
</NeAvatar>`

export const PlaceholderSlot: Story = {
render: (args) => ({
components: { NeAvatar, FontAwesomeIcon },
setup() {
return { args, faUserSecret }
},
template: placeholderSlotTemplate
}),
args: {}
}
14 changes: 14 additions & 0 deletions stories/NeCard.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,3 +220,17 @@ export const AlternateBackground: Story = {
alternateBackground: true
}
}

const withoutTitleTemplate =
'<NeCard v-bind="args">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</NeCard>'

export const WithoutTitle: Story = {
render: (args) => ({
components: { NeCard },
setup() {
return { args }
},
template: withoutTitleTemplate
}),
args: { title: '' }
}
32 changes: 29 additions & 3 deletions stories/NeDropdown.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export const AlignToRight: Story = {
args: { alignToRight: true }
}

const withSlotTemplate =
const buttonSlotTemplate =
'<NeDropdown v-bind="args">\
<template #button>\
<NeButton>\
Expand All @@ -94,13 +94,13 @@ const withSlotTemplate =
</template>\
</NeDropdown>'

export const WithSlot: Story = {
export const ButtonSlot: Story = {
render: (args) => ({
components: { NeDropdown, NeButton, FontAwesomeIcon },
setup() {
return { args, faChevronDown }
},
template: withSlotTemplate
template: buttonSlotTemplate
}),
args: {}
}
Expand All @@ -117,3 +117,29 @@ export const MenuClasses: Story = {
menuClasses: 'bg-fuchsia-200! dark:bg-fuchsia-900!'
}
}

const menuHeaderSlotTemplate = `<NeDropdown v-bind="args">
<template #menuHeader>
<div class="space-y-1 px-4 py-2 text-sm">
<div class="font-medium text-gray-900 dark:text-gray-100">
Adam Reynolds
</div>
<div class="text-gray-500 dark:text-gray-400">
adamreynolds@example.com
</div>
</div>
<hr class="my-1 border-gray-200 dark:border-gray-700"
/>
</template>
</NeDropdown>`

export const MenuHeaderSlot: Story = {
render: (args) => ({
components: { NeDropdown },
setup() {
return { args }
},
template: menuHeaderSlotTemplate
}),
args: {}
}
Loading