Add search and skin tones to profile emoji picker

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-08-05 16:40:31 +01:00
parent 067c085f37
commit c1c88ca623
4 changed files with 160 additions and 7 deletions

View File

@@ -753,7 +753,11 @@ export function ProfileAvatarEditor({
) : (
<div className="relative grid content-start gap-3">
<div
className="buzz-emoji-mart relative z-0 h-[316px] overflow-hidden rounded-xl bg-muted transition-colors duration-[250ms] ease-out"
className={cn(
"buzz-emoji-mart relative z-0 overflow-hidden rounded-xl bg-muted transition-colors duration-[250ms] ease-out",
isOnboardingModal ? "h-[316px]" : "h-[384px]",
)}
data-testid={`${testIdPrefix}-emoji-picker`}
ref={emojiPickerContainerRef}
style={emojiMartThemeVars}
>
@@ -788,9 +792,9 @@ export function ProfileAvatarEditor({
applyEmojiAvatar(emoji.native, nextColor);
}}
previewPosition="none"
searchPosition="none"
searchPosition="sticky"
set="native"
skinTonePosition="none"
skinTonePosition="search"
theme={emojiPickerTheme}
/>
</div>

View File

@@ -72,6 +72,7 @@ const EMOJI_MART_SHADOW_CSS = `
#root {
--padding: var(--buzz-emoji-picker-padding, 16px);
--buzz-emoji-picker-search-control-height: 48px;
--sidebar-width: 0px;
display: flex;
flex-direction: column;
@@ -127,6 +128,47 @@ const EMOJI_MART_SHADOW_CSS = `
display: none;
}
/* Match the app's member-search controls: a distinct resting surface and
* border make both the emoji search and its adjacent skin-tone control easy
* to find before either receives focus. */
.search input[type="search"],
.search + .flex {
background-color: rgb(var(--em-rgb-input));
box-shadow: inset 0 0 0 1px rgba(var(--em-rgb-color), 0.16);
}
.search input[type="search"] {
border-radius: 12px;
height: var(--buzz-emoji-picker-search-control-height);
padding-bottom: 0;
padding-top: 0;
}
.search input[type="search"]:focus {
box-shadow: inset 0 0 0 1px rgb(var(--em-rgb-accent));
}
.search + .flex {
border-radius: 12px;
flex: 0 0 auto;
height: var(--buzz-emoji-picker-search-control-height) !important;
margin-left: 8px;
width: var(--buzz-emoji-picker-search-control-height) !important;
}
.skin-tone-button {
background-color: transparent !important;
border: 0 !important;
border-radius: 8px;
box-shadow: none !important;
height: calc(var(--buzz-emoji-picker-search-control-height) - 8px) !important;
width: calc(var(--buzz-emoji-picker-search-control-height) - 8px) !important;
}
.skin-tone-button[aria-selected] {
background-color: transparent !important;
}
.category button .background {
background-color: transparent;
transition: background-color var(--duration) var(--easing);
@@ -593,14 +635,12 @@ export function useEmojiMartThemeVars() {
const foreground = hslToRgbString(
styles.getPropertyValue("--foreground"),
);
const background = hslToRgbString(
styles.getPropertyValue("--background"),
);
const input = hslToRgbString(styles.getPropertyValue("--input"));
setThemeVars({
"--buzz-emoji-picker-rgb-background": muted ?? "54, 58, 79",
"--buzz-emoji-picker-rgb-color": foreground ?? "245, 247, 255",
"--buzz-emoji-picker-rgb-input": background ?? "47, 51, 68",
"--buzz-emoji-picker-rgb-input": input ?? "47, 51, 68",
} as React.CSSProperties);
};

View File

@@ -38,6 +38,41 @@ test("avatar step always shows Skip for now button without an error", async ({
});
});
test("avatar step shares the profile emoji picker controls", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await page.getByRole("tab", { name: "Emoji" }).click();
const picker = page.locator("em-emoji-picker");
await expect(picker.locator("input[type='search']")).toBeVisible();
await expect(page.getByTestId("onboarding-avatar-emoji-picker")).toHaveCSS(
"height",
"384px",
);
const controlHeights = await picker.evaluate((element) => {
const input = element.shadowRoot?.querySelector<HTMLInputElement>(
'input[type="search"]',
);
const toneControl =
element.shadowRoot?.querySelector<HTMLElement>(".search + .flex");
if (!input || !toneControl) {
throw new Error("Onboarding emoji picker controls did not render.");
}
return {
input: input.getBoundingClientRect().height,
tone: toneControl.getBoundingClientRect().height,
};
});
expect(controlHeights).toEqual({ input: 48, tone: 48 });
});
test("avatar step skip button completes community profile setup", async ({
page,
}) => {

View File

@@ -585,6 +585,80 @@ test("renders emoji avatars with a static background layer", async ({
);
});
test("offers emoji search and skin-tone controls for profile avatars", async ({
page,
}) => {
await page.goto("/");
await openSettings(page, "profile");
await page.getByTestId("profile-avatar-edit").click();
await page.getByRole("tab", { name: "Emoji" }).click();
const picker = page.locator("em-emoji-picker");
const searchInput = picker.locator("input[type='search']");
await expect(searchInput).toBeVisible();
await expect(page.getByTestId("profile-avatar-emoji-picker")).toHaveCSS(
"height",
"384px",
);
// Emoji Mart renders the tone selector alongside its search field when
// `skinTonePosition` is `search`. Keeping it in the same shadow-DOM region
// ensures profile avatars match the regular in-app emoji picker.
const hasSkinToneControl = await picker.evaluate((element) => {
const controls = element.shadowRoot?.querySelectorAll("button") ?? [];
return Array.from(controls).some((button) =>
/skin tone/i.test(button.getAttribute("aria-label") ?? ""),
);
});
expect(hasSkinToneControl).toBe(true);
const controlColors = await picker.evaluate((element) => {
const root = element.shadowRoot?.querySelector<HTMLElement>("#root");
const input = element.shadowRoot?.querySelector<HTMLInputElement>(
'input[type="search"]',
);
const toneControl =
element.shadowRoot?.querySelector<HTMLElement>(".search + .flex");
if (!root || !input || !toneControl) {
throw new Error("Profile emoji picker controls did not render.");
}
return {
input: getComputedStyle(input).backgroundColor,
picker: getComputedStyle(root).backgroundColor,
tone: getComputedStyle(toneControl).backgroundColor,
};
});
expect(controlColors.input).not.toBe(controlColors.picker);
expect(controlColors.tone).not.toBe(controlColors.picker);
const controlHeights = await picker.evaluate((element) => {
const input = element.shadowRoot?.querySelector<HTMLInputElement>(
'input[type="search"]',
);
const toneControl =
element.shadowRoot?.querySelector<HTMLElement>(".search + .flex");
const toneButton =
element.shadowRoot?.querySelector<HTMLElement>(".skin-tone-button");
if (!input || !toneControl || !toneButton) {
throw new Error("Profile emoji picker controls did not render.");
}
input.focus();
return {
inputHeight: input.getBoundingClientRect().height,
inputShadow: getComputedStyle(input).boxShadow,
toneButtonBorder: getComputedStyle(toneButton).borderTopWidth,
toneButtonShadow: getComputedStyle(toneButton).boxShadow,
toneHeight: toneControl.getBoundingClientRect().height,
};
});
expect(controlHeights.inputHeight).toBe(48);
expect(controlHeights.toneHeight).toBe(48);
expect(controlHeights.inputShadow).toMatch(/inset$/);
expect(controlHeights.toneButtonBorder).toBe("0px");
expect(controlHeights.toneButtonShadow).toBe("none");
});
test("reveals emoji background colors only after choosing an emoji", async ({
page,
}) => {