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..38870199cf 100644 --- a/desktop/tests/e2e/onboarding-avatar-skip.spec.ts +++ b/desktop/tests/e2e/onboarding-avatar-skip.spec.ts @@ -2,6 +2,7 @@ import { expect, test } from "@playwright/test"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; import { waitForAnimations } from "../helpers/animations"; +import { expectEmojiMartStylesInstalled } from "../helpers/css"; import { seedActiveIdentity } from "../helpers/onboarding"; const BLANK_TYLER_IDENTITY = { @@ -38,6 +39,42 @@ 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 expectEmojiMartStylesInstalled(picker); + 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..c5175516cd 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -5,6 +5,7 @@ import { installMockBridge, TEST_IDENTITIES, } from "../helpers/bridge"; +import { expectEmojiMartStylesInstalled } from "../helpers/css"; import { openProfileMenu, openSettings } from "../helpers/settings"; async function expectHomeView(page: import("@playwright/test").Page) { @@ -585,6 +586,81 @@ 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 expectEmojiMartStylesInstalled(picker); + 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, }) => { diff --git a/desktop/tests/helpers/css.ts b/desktop/tests/helpers/css.ts index 0985fb7887..31b0ac036b 100644 --- a/desktop/tests/helpers/css.ts +++ b/desktop/tests/helpers/css.ts @@ -99,3 +99,23 @@ export async function expectSmoothCorners( ) .toBe(true); } + +/** + * Waits for the Buzz emoji-picker overrides to be present in the Emoji Mart + * shadow root. + * + * The stylesheet is appended from a `requestAnimationFrame` loop that starts + * only once the `em-emoji-picker` custom element has attached its shadow root, + * so it can land a frame or more after the search input becomes visible. Any + * assertion on an overridden property (control heights, resting background) + * must wait for this, otherwise it can read Emoji Mart's un-overridden defaults. + */ +export async function expectEmojiMartStylesInstalled(picker: Locator) { + await expect + .poll(async () => + picker.evaluate((element) => + Boolean(element.shadowRoot?.querySelector("#buzz-emoji-mart-style")), + ), + ) + .toBe(true); +}