From c1c88ca6239ea0fa929a2f4e7d2533cb42e6b180 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Wed, 5 Aug 2026 16:40:31 +0100 Subject: [PATCH] Add search and skin tones to profile emoji picker Signed-off-by: kenny lopez --- .../profile/ui/ProfileAvatarEditor.tsx | 10 ++- .../profile/ui/ProfileAvatarEditor.utils.ts | 48 +++++++++++- .../tests/e2e/onboarding-avatar-skip.spec.ts | 35 +++++++++ desktop/tests/e2e/profile.spec.ts | 74 +++++++++++++++++++ 4 files changed, 160 insertions(+), 7 deletions(-) diff --git a/desktop/src/features/profile/ui/ProfileAvatarEditor.tsx b/desktop/src/features/profile/ui/ProfileAvatarEditor.tsx index d663850482..f16cf4bd71 100644 --- a/desktop/src/features/profile/ui/ProfileAvatarEditor.tsx +++ b/desktop/src/features/profile/ui/ProfileAvatarEditor.tsx @@ -753,7 +753,11 @@ export function ProfileAvatarEditor({ ) : (
@@ -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} />
diff --git a/desktop/src/features/profile/ui/ProfileAvatarEditor.utils.ts b/desktop/src/features/profile/ui/ProfileAvatarEditor.utils.ts index c82ad26436..03b5e38026 100644 --- a/desktop/src/features/profile/ui/ProfileAvatarEditor.utils.ts +++ b/desktop/src/features/profile/ui/ProfileAvatarEditor.utils.ts @@ -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); }; diff --git a/desktop/tests/e2e/onboarding-avatar-skip.spec.ts b/desktop/tests/e2e/onboarding-avatar-skip.spec.ts index 0ee08e2f01..1aad4ea2de 100644 --- a/desktop/tests/e2e/onboarding-avatar-skip.spec.ts +++ b/desktop/tests/e2e/onboarding-avatar-skip.spec.ts @@ -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( + 'input[type="search"]', + ); + const toneControl = + element.shadowRoot?.querySelector(".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, }) => { diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index eefdef1fdd..25764c8e97 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -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("#root"); + const input = element.shadowRoot?.querySelector( + 'input[type="search"]', + ); + const toneControl = + element.shadowRoot?.querySelector(".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( + 'input[type="search"]', + ); + const toneControl = + element.shadowRoot?.querySelector(".search + .flex"); + const toneButton = + element.shadowRoot?.querySelector(".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, }) => {