From 887868f44d631a14efc7d288d1fc750e25b35277 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 11:32:20 +0000 Subject: [PATCH 01/11] docs: plan shared BccEmojiPicker component Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PJ5wpbHr6RegjqdjX2Pm1S --- .../plans/2026-10-emoji-picker.md | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 component-library/plans/2026-10-emoji-picker.md diff --git a/component-library/plans/2026-10-emoji-picker.md b/component-library/plans/2026-10-emoji-picker.md new file mode 100644 index 00000000..0cc34643 --- /dev/null +++ b/component-library/plans/2026-10-emoji-picker.md @@ -0,0 +1,97 @@ +# Plan: shared emoji picker (`BccEmojiPicker`) + +Status: proposal + +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. To fix that, +[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 +apps (for example bcc-event-app) may need the same picker later, so it belongs in the +component library and not in the chat package. + +## Decision + +Add `BccEmojiPicker` to `@bcc-code/component-library-vue` as a custom component +(`src/components/custom/BccEmojiPicker/`). The chat composer uses it in place of +`ComposerEmojiPopover`'s 36-emoji grid. + +### Why this does not grow the chat package + +- `@bcc-code/vue-bcc-chat-ui` treats `@bcc-code/component-library-vue` as an external peer + dependency (`ui/vite.config.ts`), so the picker is not bundled into the chat package. +- The component library's ES build uses `preserveModules`, so apps that never import + `BccEmojiPicker` don't pay for it. +- The emoji data (the largest part) loads with a dynamic `import()` the first time the picker + opens, so it lands in its own chunk and stays out of the initial app bundle. It is bundled + with the app, not fetched from a CDN, so it works offline in the Capacitor apps. + +### Library + +Build the UI ourselves in Vue and use [`emojibase-data`](https://emojibase.dev/) for the data +(emoji, names, groups, search keywords, skin tones, localised for `en`, `nb`, `de`, `es`, +`fr`, `nl`, `pl` and more). + +Alternatives rejected: + +- **`emoji-picker-element`**: complete and small, but it is a web component in shadow DOM and + fetches its data from a CDN by default. Matching the design tokens and dark mode through its + CSS variables only goes so far, and the CDN would need replacing for offline use. Second + choice if building our own UI takes too long. +- **`vue3-emoji-picker`, `emoji-mart`**: bundle all data eagerly or are poorly maintained for + Vue 3; the styling does not follow the design tokens. +- **Keep the 36 reaction emojis**: what we have now; the PM wants more. + +To verify before implementing: the size of the `emojibase-data` chunk (compact data, one +locale) and that the package versions used are maintained. + +## Component API (first version) + +```ts +type EmojiPickerProps = { + /** Locale for names and search keywords. Falls back to `en`. */ + locale?: string; + /** Placeholder for the search field. */ + searchPlaceholder?: string; +}; + +// emits +select: [emoji: string]; +``` + +- Inline panel only. The consumer decides where it is shown (the chat composer already + positions its own popover). No built-in trigger button. +- Search field on top, category tabs, a scrollable grid grouped by category. +- Keyboard: type to search, arrow keys in the grid, Enter selects. Every emoji button has an + `aria-label` with its name. +- Emoji buttons use `mousedown.prevent` so a focused editor keeps its caret, as in #532. +- Styling with design tokens; light and dark mode. +- 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, a built-in popover/trigger. Each can be added later without breaking the API. + +## Steps + +1. **bcc-design** (`component-library/`): add `BccEmojiPicker` with `emojibase-data` as a + dependency, story, test; export it and its prop type from `src/components/custom/index.ts`. + Release through the `design-library-*` create-version workflow. +2. **bcc-chat** (`ui/`): bump `@bcc-code/component-library-vue`, render `BccEmojiPicker` + inside `ComposerEmojiPopover` (keep its open/close and outside-click logic), pass the chat + locale and a translated search placeholder. Keep the `isTouchUI` check: mobile still uses + the system emoji keyboard. Update the Playwright emoji test and the affected snapshots. +3. **bcc-event-app**: nothing now. It can use `BccEmojiPicker` when a feature needs it. + +Both repos use the branch `feat/emoji-picker`. + +## Test by hand + +- Desktop Chrome, Safari, Firefox: open the picker, search in English and Norwegian, insert + emojis in the middle of formatted text and mentions. +- Check in the browser's network tab that the emoji data loads only when the picker opens. +- Dark mode. From d498bbf4e04d0776cba52d613cba90ec3489be6b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 11:37:35 +0000 Subject: [PATCH 02/11] docs: add emojibase-data size and maintenance check to emoji picker plan Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PJ5wpbHr6RegjqdjX2Pm1S --- component-library/plans/2026-10-emoji-picker.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/component-library/plans/2026-10-emoji-picker.md b/component-library/plans/2026-10-emoji-picker.md index 0cc34643..1154edb2 100644 --- a/component-library/plans/2026-10-emoji-picker.md +++ b/component-library/plans/2026-10-emoji-picker.md @@ -47,8 +47,16 @@ Alternatives rejected: Vue 3; the styling does not follow the design tokens. - **Keep the 36 reaction emojis**: what we have now; the PM wants more. -To verify before implementing: the size of the `emojibase-data` chunk (compact data, one -locale) and that the package versions used are maintained. +Checked 2026-10-20 (`emojibase-data` 18.0.0, MIT): + +- Size: `en/compact.json` is about 84 KB gzipped (574 KB raw), `nb/compact.json` about 80 KB + gzipped. Only the active locale's file is loaded. The full `data.json` (about 94 KB + gzipped) is not needed for the first version. +- Maintenance: 18.0.0 was published 2026-10-16 with Emoji 18. Major releases have followed + each new Emoji version (16.0 in 12/2024, 17.0 in 11/2025). +- 18.0.0 uses CLDR 49 *beta* data for labels and keywords. Emoji 18 characters may not + render on older systems, so filter the grid by what the platform supports. +- No `ro` locale; fall back to `en` there. ## Component API (first version) From bd24afe4017f5dbf8b48c3a3f6e3be6221e82986 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 11:45:02 +0000 Subject: [PATCH 03/11] docs: pin emojibase-data to Emoji 17 in emoji picker plan Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PJ5wpbHr6RegjqdjX2Pm1S --- .../plans/2026-10-emoji-picker.md | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/component-library/plans/2026-10-emoji-picker.md b/component-library/plans/2026-10-emoji-picker.md index 1154edb2..2dd88105 100644 --- a/component-library/plans/2026-10-emoji-picker.md +++ b/component-library/plans/2026-10-emoji-picker.md @@ -47,15 +47,19 @@ Alternatives rejected: Vue 3; the styling does not follow the design tokens. - **Keep the 36 reaction emojis**: what we have now; the PM wants more. -Checked 2026-10-20 (`emojibase-data` 18.0.0, MIT): - -- Size: `en/compact.json` is about 84 KB gzipped (574 KB raw), `nb/compact.json` about 80 KB - gzipped. Only the active locale's file is loaded. The full `data.json` (about 94 KB - gzipped) is not needed for the first version. -- Maintenance: 18.0.0 was published 2026-10-16 with Emoji 18. Major releases have followed - each new Emoji version (16.0 in 12/2024, 17.0 in 11/2025). -- 18.0.0 uses CLDR 49 *beta* data for labels and keywords. Emoji 18 characters may not - render on older systems, so filter the grid by what the platform supports. +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. ## Component API (first version) @@ -86,7 +90,7 @@ emojis, a built-in popover/trigger. Each can be added later without breaking the ## Steps -1. **bcc-design** (`component-library/`): add `BccEmojiPicker` with `emojibase-data` as a +1. **bcc-design** (`component-library/`): add `BccEmojiPicker` with `emojibase-data@~17.0.0` as a dependency, story, test; export it and its prop type from `src/components/custom/index.ts`. Release through the `design-library-*` create-version workflow. 2. **bcc-chat** (`ui/`): bump `@bcc-code/component-library-vue`, render `BccEmojiPicker` From 62d5b4fc82981784c341236c13dbb404dd76bd14 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 13:29:37 +0000 Subject: [PATCH 04/11] docs: rewrite emoji picker plan around a host-provided openEmojiPicker prop Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PJ5wpbHr6RegjqdjX2Pm1S --- .../plans/2026-10-emoji-picker.md | 183 ++++++++++++------ 1 file changed, 119 insertions(+), 64 deletions(-) diff --git a/component-library/plans/2026-10-emoji-picker.md b/component-library/plans/2026-10-emoji-picker.md index 2dd88105..1f6804ce 100644 --- a/component-library/plans/2026-10-emoji-picker.md +++ b/component-library/plans/2026-10-emoji-picker.md @@ -1,4 +1,4 @@ -# Plan: shared emoji picker (`BccEmojiPicker`) +# Plan: full emoji picker in the chat, provided by the host app Status: proposal @@ -6,46 +6,77 @@ ClickUp: [Støtte emojis og scheduled meldinger på pc i chat](https://app.click ## Problem -Many desktop users have no emoji keyboard, or don't know how to open it. To fix that, -[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. +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 -apps (for example bcc-event-app) may need the same picker later, so it belongs in the -component library and not in the chat package. +The PM wants a normal emoji picker: every emoji, grouped in categories, with search. It must +not make the chat package bigger, and it must not take space in the native app builds, where +the system emoji keyboard is used. ## Decision -Add `BccEmojiPicker` to `@bcc-code/component-library-vue` as a custom component -(`src/components/custom/BccEmojiPicker/`). The chat composer uses it in place of -`ComposerEmojiPopover`'s 36-emoji grid. +1. The component library gets a full emoji picker, opened from code with + `openBccEmojiPicker()`, the same way `openBccLightbox()` works. +2. `BccChat` gets an optional `openEmojiPicker` prop. When it is set, the composer's emoji + button calls it; when it is not, the composer keeps the 36-emoji grid from #532. +3. bcc-event-app passes `openEmojiPicker` to `BccChat` in the web build only. -### Why this does not grow the chat package +The chat package never imports the picker or the emoji data. The host app decides whether +there is a full picker, and other places in the app can open the same picker. -- `@bcc-code/vue-bcc-chat-ui` treats `@bcc-code/component-library-vue` as an external peer - dependency (`ui/vite.config.ts`), so the picker is not bundled into the chat package. -- The component library's ES build uses `preserveModules`, so apps that never import - `BccEmojiPicker` don't pay for it. -- The emoji data (the largest part) loads with a dynamic `import()` the first time the picker - opens, so it lands in its own chunk and stays out of the initial app bundle. It is bundled - with the app, not fetched from a CDN, so it works offline in the Capacitor apps. +Alternatives rejected: -### Library +- **Picker inside the chat package**: grows the chat package, and keeping it out of native + builds needs a separate entry point in the component library plus a module alias in + `vite_native.config.ts`. The strip-code markers don't reach code inside an npm package. +- **Keep the 36 reaction emojis**: what we have now; the PM wants more. +- **A Promise that returns one emoji**: closes the picker after each emoji. #532 keeps the + picker open so several emojis can be inserted, so the chat passes an `insert` callback + instead. -Build the UI ourselves in Vue and use [`emojibase-data`](https://emojibase.dev/) for the data -(emoji, names, groups, search keywords, skin tones, localised for `en`, `nb`, `de`, `es`, -`fr`, `nl`, `pl` and more). +## bcc-design: `openBccEmojiPicker` -Alternatives rejected: +In `component-library/src/components/custom/BccEmojiPicker/`: -- **`emoji-picker-element`**: complete and small, but it is a web component in shadow DOM and - fetches its data from a CDN by default. Matching the design tokens and dark mode through its - CSS variables only goes so far, and the CDN would need replacing for offline use. Second - choice if building our own UI takes too long. -- **`vue3-emoji-picker`, `emoji-mart`**: bundle all data eagerly or are poorly maintained for - Vue 3; the styling does not follow the design tokens. -- **Keep the 36 reaction emojis**: what we have now; the PM wants more. +```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): void; +export function closeBccEmojiPicker(): void; +``` + +- 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. +- 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. @@ -62,48 +93,72 @@ Checked 2026-10-20: - Even on 17, filter the grid by what the platform can render. - No `ro` locale; fall back to `en` there. -## Component API (first version) +Rejected libraries: -```ts -type EmojiPickerProps = { - /** Locale for names and search keywords. Falls back to `en`. */ - locale?: string; - /** Placeholder for the search field. */ - searchPlaceholder?: string; -}; +- **`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. + +## bcc-chat: `openEmojiPicker` prop -// emits -select: [emoji: string]; +New optional prop on `BccChat` (`ui/src/components/chat/BccChat.vue`), passed down to the +composer: + +```ts +openEmojiPicker?: (context: { + /** The composer's emoji button, for positioning. */ + anchor: HTMLElement; + /** Inserts the emoji at the caret. Can be called several times. */ + insert: (emoji: string) => void; +}) => void; ``` -- Inline panel only. The consumer decides where it is shown (the chat composer already - positions its own popover). No built-in trigger button. -- Search field on top, category tabs, a scrollable grid grouped by category. -- Keyboard: type to search, arrow keys in the grid, Enter selects. Every emoji button has an - `aria-label` with its name. -- Emoji buttons use `mousedown.prevent` so a focused editor keeps its caret, as in #532. -- Styling with design tokens; light and dark mode. -- Storybook story and a unit test for search and select. +- `MessageComposer` calls it from the emoji button, with `insertText` from + `useRichTextComposer` as `insert`. +- Without the prop, `ComposerEmojiPopover` and its 36 emojis work as today, so other users of + the chat package see no change. +- The emoji button stays hidden on touch devices (`useIsTouchUi`), with or without the prop. +- Playwright: a test that the prop is called and `insert` puts the emoji at the caret. The + existing emoji test keeps covering the fallback. -Left out of the first version on purpose: skin-tone selection, "recently used", custom -emojis, a built-in popover/trigger. Each can be added later without breaking the API. +## bcc-event-app: pass the prop on web + +In `src/modules/Inbox/components/ChatRoom.vue` (the only ``), pass +`:open-emoji-picker`. Its value comes from a small `.ts` module, wrapped in +`START-PWA-ONLY` / `END-PWA-ONLY` so `rollup-plugin-strip-code` removes it from the native +build. Strip-code only runs on `**/*.ts`, so the import must not be in the `.vue` file. + +```ts +export let openEmojiPicker: OpenEmojiPicker | undefined; + +/* START-PWA-ONLY */ +import { openBccEmojiPicker } from '@bcc-code/component-library-vue'; + +openEmojiPicker = ({ anchor, insert }) => openBccEmojiPicker({ anchor, onSelect: insert, locale: currentLocale() }); +/* END-PWA-ONLY */ +``` + +The native build ends up with `openEmojiPicker` as `undefined` and no import of the picker. The markers are configured in `vite_native.config.ts` but no code uses them +yet, so check the native build output once. -## Steps +## Steps and branches -1. **bcc-design** (`component-library/`): add `BccEmojiPicker` with `emojibase-data@~17.0.0` as a - dependency, story, test; export it and its prop type from `src/components/custom/index.ts`. - Release through the `design-library-*` create-version workflow. -2. **bcc-chat** (`ui/`): bump `@bcc-code/component-library-vue`, render `BccEmojiPicker` - inside `ComposerEmojiPopover` (keep its open/close and outside-click logic), pass the chat - locale and a translated search placeholder. Keep the `isTouchUI` check: mobile still uses - the system emoji keyboard. Update the Playwright emoji test and the affected snapshots. -3. **bcc-event-app**: nothing now. It can use `BccEmojiPicker` when a feature needs it. +All three repos use the branch `feat/emoji-picker`. -Both repos use the branch `feat/emoji-picker`. +1. **bcc-design**: `openBccEmojiPicker` with `emojibase-data@~17.0.0`, story, test, exports + from `src/components/custom/index.ts`. Release through the `design-library-*` + create-version workflow. +2. **bcc-chat**: `openEmojiPicker` prop and test. Release a new version of + `@bcc-code/vue-bcc-chat-ui`. +3. **bcc-event-app**: bump both packages and pass the prop on web. ## Test by hand -- Desktop Chrome, Safari, Firefox: open the picker, search in English and Norwegian, insert - emojis in the middle of formatted text and mentions. -- Check in the browser's network tab that the emoji data loads only when the picker opens. +- Web app in desktop Chrome, Safari and Firefox: open the picker from the chat, search in + English and Norwegian, insert several emojis in the middle of formatted text and mentions. +- Network tab: the picker and the emoji data load only when the picker opens. +- Native build: no emojibase files in the output. On an iPad with a trackpad, the emoji + button opens the 36-emoji fallback. - Dark mode. From c073d31ed77826fae24fea008cecfe7c71fed640 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 13:49:53 +0000 Subject: [PATCH 05/11] feat(component-library): add BccEmojiPicker and openBccEmojiPicker Full emoji picker with search, category tabs and keyboard navigation, built on emojibase-data 17. The picker and its data load on first open, and emojis the device can't render are left out. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PJ5wpbHr6RegjqdjX2Pm1S --- component-library/package.json | 1 + .../plans/2026-10-emoji-picker.md | 13 +- component-library/pnpm-lock.yaml | 18 ++ component-library/src/appContext.ts | 12 ++ .../custom/BccEmojiPicker/BccEmojiPicker.css | 33 +++ .../BccEmojiPicker/BccEmojiPicker.spec.ts | 60 ++++++ .../BccEmojiPicker/BccEmojiPicker.stories.ts | 93 ++++++++ .../custom/BccEmojiPicker/BccEmojiPicker.vue | 200 ++++++++++++++++++ .../BccEmojiPicker/BccEmojiPickerPopover.vue | 43 ++++ .../components/custom/BccEmojiPicker/data.ts | 132 ++++++++++++ .../components/custom/BccEmojiPicker/state.ts | 45 ++++ .../components/custom/BccEmojiPicker/types.ts | 33 +++ .../src/components/custom/index.ts | 3 + .../src/components/custom/styles.css | 1 + component-library/src/setup.ts | 3 + 15 files changed, 687 insertions(+), 3 deletions(-) create mode 100644 component-library/src/appContext.ts create mode 100644 component-library/src/components/custom/BccEmojiPicker/BccEmojiPicker.css create mode 100644 component-library/src/components/custom/BccEmojiPicker/BccEmojiPicker.spec.ts create mode 100644 component-library/src/components/custom/BccEmojiPicker/BccEmojiPicker.stories.ts create mode 100644 component-library/src/components/custom/BccEmojiPicker/BccEmojiPicker.vue create mode 100644 component-library/src/components/custom/BccEmojiPicker/BccEmojiPickerPopover.vue create mode 100644 component-library/src/components/custom/BccEmojiPicker/data.ts create mode 100644 component-library/src/components/custom/BccEmojiPicker/state.ts create mode 100644 component-library/src/components/custom/BccEmojiPicker/types.ts diff --git a/component-library/package.json b/component-library/package.json index deb14ec6..a52bcbe8 100644 --- a/component-library/package.json +++ b/component-library/package.json @@ -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" }, diff --git a/component-library/plans/2026-10-emoji-picker.md b/component-library/plans/2026-10-emoji-picker.md index 1f6804ce..6b924107 100644 --- a/component-library/plans/2026-10-emoji-picker.md +++ b/component-library/plans/2026-10-emoji-picker.md @@ -1,6 +1,6 @@ # Plan: full emoji picker in the chat, provided by the host app -Status: proposal +Status: in progress (bcc-design implemented) ClickUp: [Støtte emojis og scheduled meldinger på pc i chat](https://app.clickup.com/t/124kk1zbnj5) (DIGI-6074) @@ -52,12 +52,19 @@ type EmojiPickerOpenOptions = { onHide?: () => void; }; -export function openBccEmojiPicker(options: EmojiPickerOpenOptions): void; -export function closeBccEmojiPicker(): void; +export function openBccEmojiPicker(options: EmojiPickerOpenOptions): Promise; +export function closeBccEmojiPicker(): Promise; ``` +- Calling `openBccEmojiPicker` again with the same `anchor` while it is open closes it, so it + can be a button's click handler. `` 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`, diff --git a/component-library/pnpm-lock.yaml b/component-library/pnpm-lock.yaml index 5db3f84b..0061b751 100644 --- a/component-library/pnpm-lock.yaml +++ b/component-library/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@tailwindcss/vite': specifier: ^4.1.18 version: 4.1.18(vite@7.3.5(@types/node@24.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) + emojibase-data: + specifier: ~17.0.0 + version: 17.0.0(emojibase@17.0.0) primevue: specifier: ^4.5.5 version: 4.5.5(vue@3.5.43(typescript@5.7.3)) @@ -2813,6 +2816,15 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + emojibase-data@17.0.0: + resolution: {integrity: sha512-Yvgb5AWoHViHV/gq1qr5ZAarcBip+B27/ZLRsUJkbgAEaLlZ/fof9g882LTpmEpyhBNEC0m2SEmItljHsTygjA==} + peerDependencies: + emojibase: '*' + + emojibase@17.0.0: + resolution: {integrity: sha512-bXdpf4HPY3p41zK5swVKZdC/VynsMZ4LoLxdYDE+GucqkFwzcM1GVc4ODfYAlwoKaf2U2oNNUoOO78N96ovpBA==} + engines: {node: '>=18.12.0'} + emojilib@2.4.0: resolution: {integrity: sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==} @@ -8120,6 +8132,12 @@ snapshots: emoji-regex@9.2.2: {} + emojibase-data@17.0.0(emojibase@17.0.0): + dependencies: + emojibase: 17.0.0 + + emojibase@17.0.0: {} + emojilib@2.4.0: {} enabled@2.0.0: {} diff --git a/component-library/src/appContext.ts b/component-library/src/appContext.ts new file mode 100644 index 00000000..98f9e817 --- /dev/null +++ b/component-library/src/appContext.ts @@ -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; +} diff --git a/component-library/src/components/custom/BccEmojiPicker/BccEmojiPicker.css b/component-library/src/components/custom/BccEmojiPicker/BccEmojiPicker.css new file mode 100644 index 00000000..92c50f10 --- /dev/null +++ b/component-library/src/components/custom/BccEmojiPicker/BccEmojiPicker.css @@ -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; + } +} diff --git a/component-library/src/components/custom/BccEmojiPicker/BccEmojiPicker.spec.ts b/component-library/src/components/custom/BccEmojiPicker/BccEmojiPicker.spec.ts new file mode 100644 index 00000000..10a8e93a --- /dev/null +++ b/component-library/src/components/custom/BccEmojiPicker/BccEmojiPicker.spec.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; + +import { flushPromises, mount } from '@vue/test-utils'; +import PrimeVue from 'primevue/config'; +import BccEmojiPicker from './BccEmojiPicker.vue'; +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'); + }); +}); diff --git a/component-library/src/components/custom/BccEmojiPicker/BccEmojiPicker.stories.ts b/component-library/src/components/custom/BccEmojiPicker/BccEmojiPicker.stories.ts new file mode 100644 index 00000000..55f89797 --- /dev/null +++ b/component-library/src/components/custom/BccEmojiPicker/BccEmojiPicker.stories.ts @@ -0,0 +1,93 @@ +import { MoodIcon } from '@bcc-code/icons-vue'; +import type { Meta, StoryObj } from '@storybook/vue3-vite'; +import { ref } from 'vue'; + +import BccButton from '../../wrapped/BccButton.vue'; +import BccEmojiPicker from './BccEmojiPicker.vue'; +import { openBccEmojiPicker } from './state'; + +/** + * Emoji picker with search and categories, using [Emojibase](https://emojibase.dev/) data. + * Emojis the device can't render (newer Emoji versions, or flags on Windows) are left out. + * + * - **`openBccEmojiPicker({ anchor, onSelect })`** opens it in a popover next to `anchor`. It stays + * open so several emojis can be picked, and closes on outside click or Escape. Calling it again + * with the same anchor closes it. + * - **``** renders the panel inline. + * + * The picker and its data are loaded the first time it is shown, so apps that never use it don't + * bundle or download it. + */ +const meta: Meta = { + component: BccEmojiPicker, + title: 'Custom/Input/BccEmojiPicker', + argTypes: { + locale: { control: 'select', options: ['en', 'nb', 'de', 'es', 'fr', 'nl', 'pl'] }, + searchPlaceholder: { control: 'text' }, + noResultsText: { control: 'text' }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + locale: 'en', + }, + render: args => ({ + components: { BccEmojiPicker }, + setup() { + const picked = ref(''); + return { args, picked }; + }, + template: ` +
+
+ +
+

Picked: {{ picked }}

+
+ `, + }), +}; + +export const Norwegian: Story = { + ...Default, + args: { + locale: 'nb', + searchPlaceholder: 'Søk', + noResultsText: 'Fant ingen emojier', + }, +}; + +/** `openBccEmojiPicker` inserts at the caret. The buttons in the picker don't take focus, so the caret stays in the field. */ +export const Popover: Story = { + render: () => ({ + components: { BccButton }, + setup() { + const text = ref('Hello '); + const input = ref(null); + + function insert(emoji: string) { + const field = input.value; + if (!field) return; + field.setRangeText(emoji, field.selectionStart, field.selectionEnd, 'end'); + text.value = field.value; + } + + function open(event: MouseEvent) { + openBccEmojiPicker({ anchor: event.currentTarget as HTMLElement, onSelect: insert }); + } + + return { text, input, open, MoodIcon }; + }, + template: ` +
+