Skip to content
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

fix(OnyxAvatar): update default initials #2450

Merged
merged 20 commits into from
Jan 28, 2025
Merged
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
19 changes: 19 additions & 0 deletions .changeset/fast-flies-try.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"sit-onyx": major
---

refactor(OnyxAvatar): update default initials

Previously the initials were taken from the first two words. Now they will be determined as described [here](https://github.com/SchwarzIT/onyx/issues/2454) by considering the locale.
If the username contains unsupported characters (e.g. for some Korean characters) a fallback icon will be displayed.

Example for "John Middlename Doe":

- Previously: "JM"
- Now: "JD"

#### Breaking changes

- OnyxAvatar: The `label` property has been removed in favor of `fullName` which now als supports passing a locale for determining the initials (will use the i18n locale by default).
- OnyxAvatar: The default slot has been removed in favor of the `initials` property to set custom initials.
- OnyxUsername: The `username` property has been renamed to `fullName` to align with the OnyxAvatar
2 changes: 1 addition & 1 deletion apps/demo-app/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ watch(
/>

<template #contextArea>
<OnyxUserMenu username="John Doe">
<OnyxUserMenu full-name="John Doe">
<OnyxColorSchemeMenuItem v-model="colorScheme" />

<OnyxMenuItem color="danger">
Expand Down
2 changes: 1 addition & 1 deletion apps/demo-app/src/views/HomeView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ const selectedDate = ref<DateValue>();
</OnyxAccordionItem>
</OnyxAccordion>

<OnyxAvatar v-if="show('OnyxAvatar')" label="John Doe" />
<OnyxAvatar v-if="show('OnyxAvatar')" full-name="John Doe" />

<OnyxBadge v-if="show('OnyxBadge')">Badge</OnyxBadge>
<OnyxButton v-if="show('OnyxButton')" label="Button" />
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/src/development/theming.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ useThemeTransition(colorScheme);
<template>
<OnyxNavBar app-name="Example app">
<template #contextArea>
<OnyxUserMenu username="John Doe">
<OnyxUserMenu full-name="John Doe">
<OnyxColorSchemeMenuItem v-model="colorScheme" />
</OnyxUserMenu>
</template>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export const createMenuButton = createBuilder((options: CreateMenuButtonOptions)
const currentMenu = currentMenuItem?.closest('[role="menu"]') || menuRef.value;
if (!currentMenu) return;

const menuItems = [...currentMenu.querySelectorAll<HTMLElement>('[role="menuitem"]')];
const menuItems = Array.from(currentMenu.querySelectorAll<HTMLElement>('[role="menuitem"]'));
let nextIndex = 0;

if (currentMenuItem) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export const createNavigationMenu = createBuilder(({ navigationName }: CreateNav
const getMenuButtons = () => {
const nav = navId ? document.getElementById(navId) : undefined;
if (!nav) return [];
return [...nav.querySelectorAll<HTMLElement>("button[aria-expanded][aria-controls]")];
return Array.from(nav.querySelectorAll<HTMLElement>("button[aria-expanded][aria-controls]"));
};

const focusRelative = (trigger: HTMLElement, next: "next" | "previous") => {
Expand Down
2 changes: 1 addition & 1 deletion packages/nuxt/playground/components/NavBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import settings from "@sit-onyx/icons/settings.svg?raw";
</OnyxNavButton>

<template #contextArea>
<OnyxUserMenu description="Company Name" username="Jane Doe">
<OnyxUserMenu description="Company Name" full-name="Jane Doe">
<OnyxMenuItem>
<OnyxIcon :icon="settings" />
Settings
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
52 changes: 33 additions & 19 deletions packages/sit-onyx/src/components/OnyxAvatar/OnyxAvatar.ct.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ const MOCK_CUSTOM_IMAGE =

const MOCK_IMAGE_URL = "/custom-image.svg" as const;

/**
* @see: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Segmenter#examples
*/
const UNSUPPORTED_USERNAME_EXAMPLE = "吾輩は猫である。名前はたぬき。";

test.beforeEach(async ({ page }) => {
await page.route(MOCK_IMAGE_URL, (route) => {
return route.fulfill({ body: MOCK_CUSTOM_IMAGE, contentType: "image/svg+xml" });
Expand All @@ -22,18 +27,20 @@ test.describe("Screenshot tests", () => {

executeMatrixScreenshotTest({
name: "Avatar",
columns: ["default", "custom"],
columns: ["default", "custom", "unsupported-characters"],
rows: AVATAR_SIZES,
component: (column, row) => (
<OnyxAvatar
label="John Doe"
fullName={column === "unsupported-characters" ? UNSUPPORTED_USERNAME_EXAMPLE : "John Doe"}
size={row}
src={column === "custom" ? MOCK_IMAGE_URL : undefined}
/>
),
hooks: {
beforeEach: async (component) => {
await expect(component.getByTitle("John Doe")).toBeVisible();
beforeEach: async (component, page, column) => {
const username =
column === "unsupported-characters" ? UNSUPPORTED_USERNAME_EXAMPLE : "John Doe";
await expect(component.getByLabel(`Avatar of ${username}`)).toBeVisible();
},
},
});
Expand All @@ -43,9 +50,11 @@ test.describe("Screenshot tests", () => {
columns: ["default", "truncation"],
rows: AVATAR_SIZES,
component: (column, row) => (
<OnyxAvatar label="Custom content" size={row}>
{column === "truncation" ? "+999999" : "+42"}
</OnyxAvatar>
<OnyxAvatar
fullName="Custom content"
size={row}
initials={column === "truncation" ? "+999999" : "+42"}
/>
),
});
});
Expand All @@ -54,53 +63,58 @@ test("should contain correct initials", async ({ mount }) => {
// ARRANGE
const component = await mount(OnyxAvatar, {
props: {
label: "A B C D",
fullName: "A B C D",
},
});

// ASSERT
await expect(component).toContainText("AB");
await expect(component).toContainText("AD");

// ACT
await component.update({ props: { label: "abcd" } });
await component.update({ props: { fullName: "abcd" } });

// ASSERT
await expect(component).toContainText("AB");

// ACT
await component.update({ props: { label: "a" } });
await component.update({ props: { fullName: "a" } });

// ASSERT
await expect(component).toContainText("A");

// ACT
await component.update({ props: { fullName: "abcd", initials: "HI" } });

// ASSERT
await expect(component).toContainText("HI");
});

test("should show custom image", async ({ mount, page }) => {
// ARRANGE
const component = await mount(OnyxAvatar, {
props: {
label: "Custom image",
fullName: "Custom image",
src: MOCK_IMAGE_URL,
},
});

const initials = component.getByText("CI");

// ASSERT
await expect(component).not.toContainText("CI");
await expect(component.getByAltText("Custom image")).toBeVisible();
await expect(initials).toBeHidden();

// ARRANGE (should display fallback if image error occurs)
await page.route("https:/does-not-exist", (route) => route.fulfill({ status: 404 }));
await page.route("https://does-not-exist", (route) => route.fulfill({ status: 404 }));

// ACT
await component.update({ props: { src: "https://does-not-exist" } });

// ASSERT
await expect(component.getByAltText("Custom image")).toBeHidden();
await expect(component).toContainText("CI");
await expect(initials).toBeVisible();

// ACT (should reset error if image is changed)
await component.update({ props: { src: MOCK_IMAGE_URL } });

// ASSERT
await expect(component).not.toContainText("CI");
await expect(component.getByAltText("Custom image")).toBeVisible();
await expect(initials).toBeHidden();
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,6 @@ import OnyxAvatar from "./OnyxAvatar.vue";
const meta: Meta<typeof OnyxAvatar> = {
title: "Basic/Avatar",
component: OnyxAvatar,
argTypes: {
default: { control: { type: "text" } },
},
};

export default meta;
Expand All @@ -20,7 +17,7 @@ type Story = StoryObj<typeof OnyxAvatar>;
*/
export const Default = {
args: {
label: "John Doe",
fullName: "John Middlename Doe",
},
} satisfies Story;

Expand All @@ -30,17 +27,17 @@ export const Default = {
*/
export const WithImage = {
args: {
label: "onyx logo",
fullName: "onyx logo",
src: "https://onyx.schwarz/favicon.svg",
},
} satisfies Story;

/**
* This example shows an avatar with custom content instead of the default initials.
* This example shows an avatar with custom initials instead of the automatically detected ones.
*/
export const WithCustomInitials = {
args: {
label: "4 more avatars",
default: "+4",
fullName: "4 more avatars",
initials: "+4",
},
} satisfies Story;
79 changes: 42 additions & 37 deletions packages/sit-onyx/src/components/OnyxAvatar/OnyxAvatar.vue
Original file line number Diff line number Diff line change
@@ -1,51 +1,60 @@
<script lang="ts" setup>
import user from "@sit-onyx/icons/user.svg?raw";
import { computed, ref, watch } from "vue";
import { injectI18n } from "../../i18n";
import { getInitials } from "../../utils/strings";
import OnyxIcon from "../OnyxIcon/OnyxIcon.vue";
import type { OnyxAvatarProps } from "./types";

const props = withDefaults(defineProps<OnyxAvatarProps>(), {
size: "48px",
});

const slots = defineSlots<{
JoCa96 marked this conversation as resolved.
Show resolved Hide resolved
/**
* Optional slot to override the default initials. Will only be used if `type` is `initials`.
*/
default?(): unknown;
}>();
const { locale, t } = injectI18n();

const username = computed(() => {
if (typeof props.fullName === "object") return props.fullName;
return { name: props.fullName, locale: locale.value };
});

const initials = computed(() => {
const names = props.label.split(" ");
const initials =
names.length > 1 ? `${names[0].charAt(0)}${names[1].charAt(0)}` : names[0].substring(0, 2);
return initials.toUpperCase();
if (props.initials) return props.initials;
return getInitials(username.value.name, username.value.locale);
});

const hasImageError = ref(false);
const ariaLabel = computed(() => t.value("avatar.ariaLabel", { fullName: username.value.name }));

// reset image error if image changes
const hasImageError = ref(false);
watch(
// reset image error if image changes
() => props.src,
() => (hasImageError.value = false),
);
</script>

<template>
<figure
class="onyx-component onyx-avatar"
:class="[`onyx-avatar--${props.size}`, slots.default ? 'onyx-avatar--custom' : '']"
:title="props.label"
:class="[
'onyx-component',
'onyx-avatar',
`onyx-avatar--${props.size}`,
props.initials ? 'onyx-avatar--custom' : '',
]"
:title="ariaLabel"
:aria-label="ariaLabel"
>
<img
v-if="props.src && !hasImageError"
class="onyx-avatar__svg"
class="onyx-avatar__image"
:src="props.src"
:alt="props.label"
:alt="ariaLabel"
@error="hasImageError = true"
/>

<div v-else class="onyx-avatar__initials">
<slot>{{ initials }}</slot>
</div>
<template v-else>
<div v-if="initials" class="onyx-avatar__initials">{{ initials }}</div>
<OnyxIcon v-else :icon="user" class="onyx-avatar__icon" />
</template>
</figure>
</template>

Expand All @@ -59,37 +68,33 @@ watch(
height: var(--onyx-avatar-size);
min-width: var(--onyx-avatar-size);
border-radius: var(--onyx-radius-full);
background-color: var(--onyx-color-base-primary-200);
display: block;

&:has(.onyx-avatar__initials) {
background-color: var(--onyx-color-base-primary-200);
}
color: var(--onyx-color-text-icons-primary-bold);
font-family: var(--onyx-font-family);
line-height: normal;
font-weight: 600;

display: flex;
align-items: center;
justify-content: center;

&--custom {
--onyx-avatar-padding: var(--onyx-spacing-sm);
width: max-content; // allow avatar to get pill-shaped if longer custom text is passed
padding: var(--onyx-avatar-padding);
}

&__svg {
&__image {
border-radius: inherit;
height: 100%;
width: 100%;
background-color: var(--onyx-color-base-neutral-100);
object-fit: cover;
}

&__initials {
color: var(--onyx-color-text-icons-primary-bold);
font-family: var(--onyx-font-family);
line-height: normal;
font-weight: 600;
height: 100%;
width: 100%;
border-radius: inherit;

display: flex;
align-items: center;
justify-content: center;
&__icon {
--icon-size: 1em;
}

@include sizes.define-rem-sizes using ($name, $size) {
Expand Down
Loading
Loading