Skip to content
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
1 change: 1 addition & 0 deletions component-library/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
"@bcc-code/icons-vue": "^1.5.4",
"@primeuix/themes": "^2.0.3",
"@tailwindcss/vite": "^4.1.18",
"emojibase-data": "~17.0.0",
"primevue": "^4.5.5",
"tailwindcss": "^4.0.0"
},
Expand Down
104 changes: 104 additions & 0 deletions component-library/plans/2026-10-emoji-picker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Plan: shared emoji picker (`BccEmojiPicker`)

Status: implemented ([bcc-code/bcc-design#433](https://github.com/bcc-code/bcc-design/pull/433))

ClickUp: [Støtte emojis og scheduled meldinger på pc i chat](https://app.clickup.com/t/124kk1zbnj5) (DIGI-6074)

## Problem

Many desktop users have no emoji keyboard, or don't know how to open it. Browsers can't open
the system emoji panel (Win + . or Ctrl + Cmd + Space) from a web page, so we need our own
picker. [bcc-code/bcc-chat#532](https://github.com/bcc-code/bcc-chat/pull/532) added an emoji
button to the chat composer. To keep the chat package small, it shows only the 36 reaction
emojis (`ui/src/chat/reactions.ts`), with no search and no categories.

The PM wants a normal emoji picker: every emoji, grouped in categories, with search. Other
places in the apps may want one too, so it belongs in the component library. Apps that don't
use it must not pay for it in bundle size.

## Decision

The component library gets a full emoji picker, opened from code with `openBccEmojiPicker()`
(the same way `openBccLightbox()` works) or rendered inline with `<BccEmojiPicker>`.

Using it in the chat is planned in bcc-chat: the chat gets an `openEmojiPicker` prop, and the
host app passes `openBccEmojiPicker` to it in its web build only. The chat package then never
imports the picker or its data.

## `openBccEmojiPicker`

In `component-library/src/components/custom/BccEmojiPicker/`:

```ts
type EmojiPickerOpenOptions = {
/** Element the picker is positioned next to (e.g. the button that opened it). */
anchor: HTMLElement;
/** Called for each emoji the user picks. The picker stays open. */
onSelect: (emoji: string) => void;
/** Locale for names and search keywords. Falls back to `en`. */
locale?: string;
onHide?: () => void;
};

export function openBccEmojiPicker(options: EmojiPickerOpenOptions): Promise<void>;
export function closeBccEmojiPicker(): Promise<void>;
```

- Calling `openBccEmojiPicker` again with the same `anchor` while it is open closes it, so it
can be a button's click handler. `<BccEmojiPicker @select>` is also exported for inline use.
- Built on PrimeVue's `Popover`. PrimeVue components need the app's context, so `setup.ts`
remembers the app (`src/appContext.ts`) without importing the picker.
- Mounts itself with `render()` on first use, like `BccLightbox/state.ts`, but is **not**
installed in `setup.ts`, so apps that never call it don't bundle it.
- Unsupported emojis are detected by drawing a test face per Emoji version (and 🇳🇴 for flags)
on a canvas: color emoji fonts ignore the fill color, a missing glyph does not.
- The picker component and the emoji data load with dynamic `import()` on first open, so
they stay out of the app's initial bundle.
- Load the data with one explicit `import()` per supported locale (`en`, `nb`, `de`, `es`,
`fr`, `nl`, `pl`), not a template string: a template makes Vite emit a file for every
emojibase locale (about 30, ~16 MB raw).
- UI: search field on top, category tabs, a scrollable grid grouped by category. Positioned
next to `anchor`, closes on outside click or Escape. Type to search, arrow keys in the grid,
Enter selects. Every emoji button has an `aria-label` with its name. Design tokens; light
and dark mode.
- Emoji buttons use `mousedown.prevent` so a focused editor keeps its caret, as in #532.
- Storybook story and a unit test for search and select.

Left out of the first version on purpose: skin-tone selection, "recently used", custom
emojis. Each can be added later without breaking the API.

### Data: `emojibase-data`

Build the UI ourselves in Vue and use [`emojibase-data`](https://emojibase.dev/) for the data
(emoji, names, groups, search keywords, localised).

Pin `emojibase-data` to `~17.0.0` (Emoji 17, CLDR 48). Upgrade to 18 once Emoji 18 is
common on our users' devices.

Checked 2026-10-20:

- Size (17.0.0): `en/compact.json` is about 83 KB gzipped (571 KB raw, 1,949 emoji),
`nb/compact.json` about 79 KB gzipped. Only the active locale's file is loaded.
- Maintenance: MIT. Major releases follow each new Emoji version (16.0 in 12/2024, 17.0 in
11/2025, 18.0 in 10/2026).
- 18.0.0 was skipped for now: it adds Emoji 18 characters that older systems can't render,
and its labels come from CLDR 49 beta. It also drops the redundant `FE0F` from 152 emoji
(like 👍), so check that saved reactions and inserted emoji still match when upgrading.
- Even on 17, filter the grid by what the platform can render.
- No `ro` locale; fall back to `en` there.

Rejected libraries:

- **`emoji-picker-element`**: complete and small, but a web component in shadow DOM that
fetches its data from a CDN by default. Hard to match the design tokens, and the CDN would
need replacing for offline use.
- **`vue3-emoji-picker`, `emoji-mart`**: bundle all data eagerly or are poorly maintained for
Vue 3; the styling does not follow the design tokens.

## Test by hand

- Storybook (`Custom/Input/BccEmojiPicker`) in desktop Chrome, Safari and Firefox: search in
English and Norwegian, insert several emojis at the caret in the Popover story.
- Windows: flags are hidden in Chrome, and no empty squares show up.
- Network tab: the picker and the emoji data load only when the picker opens.
- Dark mode.
18 changes: 18 additions & 0 deletions component-library/pnpm-lock.yaml

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

12 changes: 12 additions & 0 deletions component-library/src/appContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { App, AppContext } from 'vue';

let appContext: AppContext | null = null;

/** Remembers the app that installed the library, so imperatively mounted components get its plugins. */
export function setBccApp(app: App) {
appContext = app._context;
}

export function getBccAppContext(): AppContext | null {
return appContext;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
@layer components {
.bcc-emoji-picker {
@apply bg-elevation-surface-default flex w-80 max-w-full flex-col;
}
.bcc-emoji-picker-search {
@apply px-2 pt-2;
}
.bcc-emoji-picker-tabs {
@apply border-default flex justify-between border-b px-2 py-1;
}
.bcc-emoji-picker-tab {
@apply text-subtle hover:bg-elevation-surface-overlay-hovered flex size-8 cursor-pointer items-center justify-center rounded-md;
}
.bcc-emoji-picker-tab--active {
@apply text-default bg-elevation-surface-overlay-hovered;
}
/* `relative` makes the sections' offsetTop relative to the scroll container. */
.bcc-emoji-picker-scroll {
@apply relative h-72 overflow-y-auto px-2 pb-2;
}
.bcc-emoji-picker-group-label {
@apply label-sm text-subtle bg-elevation-surface-default sticky top-0 z-10 py-1.5 first-letter:uppercase;
}
.bcc-emoji-picker-grid {
@apply grid grid-cols-8;
}
.bcc-emoji-picker-item {
@apply hover:bg-elevation-surface-overlay-hovered focus-visible:bg-elevation-surface-overlay-hovered flex aspect-square cursor-pointer items-center justify-center rounded-md text-xl leading-none outline-none;
}
.bcc-emoji-picker-empty {
@apply body-sm text-subtle py-6 text-center;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, expect, it, vi } from 'vitest';

import { flushPromises, mount } from '@vue/test-utils';
import PrimeVue from 'primevue/config';
import BccEmojiPicker from './BccEmojiPicker.vue';
import * as data from './data';
import { loadEmojiGroups, resolveEmojiLocale, searchEmojis } from './data';

// Emoji 17 data still has the redundant variation selector on emojis like 👍.
const plain = (emoji: string) => emoji.replace(/\uFE0F/g, '');
const global = { plugins: [PrimeVue] };

describe('BccEmojiPicker data', () => {
it('falls back to supported locales', () => {
expect(resolveEmojiLocale('nb-NO')).toBe('nb');
expect(resolveEmojiLocale('no')).toBe('nb');
expect(resolveEmojiLocale('ro')).toBe('en');
expect(resolveEmojiLocale()).toBe('en');
});

it('groups emojis without the skin tone components', async () => {
const groups = await loadEmojiGroups('en');

expect(groups.map(group => group.key)).not.toContain('component');
expect(groups[0]!.key).toBe('smileys-emotion');
expect(groups[0]!.emojis.some(item => item.emoji === '😀')).toBe(true);
});

it('searches labels and keywords in the locale', async () => {
const groups = await loadEmojiGroups('nb');

expect(searchEmojis(groups, 'tommel opp').map(item => plain(item.emoji))).toContain('👍');
expect(searchEmojis(groups, ' ')).toEqual([]);
});
});

describe('BccEmojiPicker', () => {
it('emits the emoji found by search', async () => {
const wrapper = mount(BccEmojiPicker, { props: { locale: 'en' }, global });
await flushPromises();

await wrapper.find('input').setValue('thumbs up');
const button = wrapper.find('button[aria-label="thumbs up"]');
await button.trigger('click');

expect(wrapper.emitted<[string]>('select')?.map(([emoji]) => plain(emoji))).toEqual(['👍']);
});

it('selects the first result with Enter and shows a message without results', async () => {
const wrapper = mount(BccEmojiPicker, { props: { locale: 'en', noResultsText: 'Nothing' }, global });
await flushPromises();
const input = wrapper.find('input');

await input.setValue('grinning face');
await input.trigger('keydown', { key: 'Enter' });
expect(wrapper.emitted('select')?.[0]).toEqual(['😀']);

await input.setValue('zzzz');
expect(wrapper.text()).toContain('Nothing');
});

it('moves focus from the top row back to the search field with ArrowUp', async () => {
const wrapper = mount(BccEmojiPicker, { props: { locale: 'en' }, global, attachTo: document.body });
await flushPromises();
const first = wrapper.find('.bcc-emoji-picker-item');

(first.element as HTMLElement).focus();
await first.trigger('keydown', { key: 'ArrowUp' });

expect(document.activeElement).toBe(wrapper.find('input').element);
wrapper.unmount();
});

it('shows the error text when the data fails to load', async () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.spyOn(data, 'loadEmojiGroups').mockRejectedValueOnce(new Error('offline'));
const wrapper = mount(BccEmojiPicker, { props: { locale: 'en', errorText: 'Failed' }, global });
await flushPromises();

expect(wrapper.text()).toContain('Failed');
spy.mockRestore();
});
});
Loading
Loading