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 new file mode 100644 index 00000000..4181458b --- /dev/null +++ b/component-library/plans/2026-10-emoji-picker.md @@ -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 ``. + +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; +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`, + `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. 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..071a3d7e --- /dev/null +++ b/component-library/src/components/custom/BccEmojiPicker/BccEmojiPicker.spec.ts @@ -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(); + }); +}); 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..967c21fe --- /dev/null +++ b/component-library/src/components/custom/BccEmojiPicker/BccEmojiPicker.stories.ts @@ -0,0 +1,154 @@ +import { MoodIcon } from '@bcc-code/icons-vue'; +import type { Meta, StoryObj } from '@storybook/vue3-vite'; +import { ref, useTemplateRef } from 'vue'; + +import { BccButton, BccTextarea } from '../../../index'; +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' }, + errorText: { 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', + errorText: 'Kunne ikke laste emojier', + }, + parameters: { + docs: { + source: { + code: ` + + + + `, + }, + }, + }, +}; + +/** Pass `insertAtCaret` from `BccTextarea` or `BccInput` as `onSelect` to insert 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, BccTextarea }, + setup() { + const text = ref('Hello '); + // In an SFC, useTemplateRef('input') infers this type from the template. + const input = useTemplateRef>('input'); + + function open(event: MouseEvent) { + openBccEmojiPicker({ + anchor: event.currentTarget as HTMLElement, + onSelect: emoji => input.value?.insertAtCaret(emoji), + }); + } + + return { text, input, open, MoodIcon }; + }, + template: ` +
+ + +
+ `, + }), + parameters: { + docs: { + source: { + code: ` + + + + `, + }, + }, + }, +}; diff --git a/component-library/src/components/custom/BccEmojiPicker/BccEmojiPicker.vue b/component-library/src/components/custom/BccEmojiPicker/BccEmojiPicker.vue new file mode 100644 index 00000000..18a10c4c --- /dev/null +++ b/component-library/src/components/custom/BccEmojiPicker/BccEmojiPicker.vue @@ -0,0 +1,220 @@ + + + diff --git a/component-library/src/components/custom/BccEmojiPicker/BccEmojiPickerPopover.vue b/component-library/src/components/custom/BccEmojiPicker/BccEmojiPickerPopover.vue new file mode 100644 index 00000000..f1d20ff9 --- /dev/null +++ b/component-library/src/components/custom/BccEmojiPicker/BccEmojiPickerPopover.vue @@ -0,0 +1,55 @@ + + + diff --git a/component-library/src/components/custom/BccEmojiPicker/data.ts b/component-library/src/components/custom/BccEmojiPicker/data.ts new file mode 100644 index 00000000..d4b08cd2 --- /dev/null +++ b/component-library/src/components/custom/BccEmojiPicker/data.ts @@ -0,0 +1,130 @@ +import type { EmojiGroup, EmojiItem } from './types'; + +type RawEmoji = { group?: number; label: string; tags?: string[]; unicode: string; hexcode: string }; +type RawMessages = { groups: { key: string; message: string; order: number }[] }; +type LocaleData = [{ default: unknown }, { default: unknown }]; + +// One explicit import per locale: a template string would make bundlers emit every emojibase locale. +const loaders: Record Promise> = { + en: () => Promise.all([import('emojibase-data/en/compact.json'), import('emojibase-data/en/messages.json')]), + nb: () => Promise.all([import('emojibase-data/nb/compact.json'), import('emojibase-data/nb/messages.json')]), + de: () => Promise.all([import('emojibase-data/de/compact.json'), import('emojibase-data/de/messages.json')]), + es: () => Promise.all([import('emojibase-data/es/compact.json'), import('emojibase-data/es/messages.json')]), + fr: () => Promise.all([import('emojibase-data/fr/compact.json'), import('emojibase-data/fr/messages.json')]), + nl: () => Promise.all([import('emojibase-data/nl/compact.json'), import('emojibase-data/nl/messages.json')]), + pl: () => Promise.all([import('emojibase-data/pl/compact.json'), import('emojibase-data/pl/messages.json')]), +}; + +/** A face from each recent Emoji version, newest first. Older versions are assumed to render. */ +const VERSION_TEST_EMOJIS: [version: number, emoji: string][] = [ + [17, 'πŸ«ͺ'], + [16, '🫩'], + [15, '🫨'], + [14, '🫠'], + [13, 'πŸ₯²'], + [12, 'πŸ₯±'], + [11, 'πŸ₯°'], +]; + +export function resolveEmojiLocale(locale?: string): string { + const language = locale?.toLowerCase().split(/[-_]/)[0] ?? 'en'; + if (language === 'no' || language === 'nn') return 'nb'; + return language in loaders ? language : 'en'; +} + +const cache = new Map>(); + +/** Loads emojis grouped by category, without the ones this device can't render. */ +export function loadEmojiGroups(locale?: string): Promise { + const key = resolveEmojiLocale(locale); + let groups = cache.get(key); + if (!groups) { + groups = buildGroups(key); + cache.set(key, groups); + groups.catch(() => cache.delete(key)); + } + return groups; +} + +async function buildGroups(locale: string): Promise { + const [[emojiModule, messagesModule], versionsModule] = await Promise.all([ + loaders[locale]!(), + import('emojibase-data/versions/emoji.json'), + ]); + const emojis = emojiModule.default as RawEmoji[]; + const messages = messagesModule.default as RawMessages; + const versions = versionsModule.default as Record; + + const maxVersion = detectMaxEmojiVersion(); + const unsupported = new Set( + Object.entries(versions) + .filter(([version]) => Number(version) > maxVersion) + .flatMap(([, hexcodes]) => hexcodes) + ); + const showFlags = canRenderColor('πŸ‡³πŸ‡΄'); + + const groups = messages.groups + // `component` holds the bare skin tone and hair modifiers, which aren't emojis on their own. + .filter(group => group.key !== 'component' && (showFlags || group.key !== 'flags')) + .map(group => ({ key: group.key, order: group.order, label: group.message, emojis: [] as EmojiItem[] })); + const byOrder = new Map(groups.map(group => [group.order, group])); + + for (const emoji of emojis) { + if (emoji.group == null || unsupported.has(emoji.hexcode)) continue; + byOrder.get(emoji.group)?.emojis.push({ emoji: emoji.unicode, label: emoji.label, tags: emoji.tags ?? [] }); + } + + return groups.map(({ key, label, emojis }) => ({ key, label, emojis })); +} + +export function searchEmojis(groups: EmojiGroup[], query: string): EmojiItem[] { + const terms = query.toLowerCase().trim().split(/\s+/).filter(Boolean); + if (!terms.length) return []; + + return groups + .flatMap(group => group.emojis) + .filter(item => { + const words = [item.label.toLowerCase(), ...item.tags.map(tag => tag.toLowerCase())]; + return terms.every(term => words.some(word => word.includes(term))); + }); +} + +function detectMaxEmojiVersion(): number { + for (const [version, emoji] of VERSION_TEST_EMOJIS) { + if (canRenderColor(emoji)) return version; + } + return VERSION_TEST_EMOJIS.at(-1)![0] - 1; +} + +let context: CanvasRenderingContext2D | null | undefined; + +/** + * Color emoji fonts ignore the fill color, while a missing glyph (or a flag shown as two + * letters) is drawn in it. So any non-gray pixel means the emoji renders. + */ +function canRenderColor(emoji: string): boolean { + if (context === undefined) { + try { + context = document.createElement('canvas').getContext('2d', { willReadFrequently: true }); + } catch { + context = null; + } + } + // No canvas (e.g. tests or SSR): show everything. + if (!context) return true; + + const size = 20; + context.canvas.width = size; + context.canvas.height = size; + context.textBaseline = 'top'; + context.font = `${size - 4}px sans-serif`; + context.fillStyle = '#000'; + context.clearRect(0, 0, size, size); + context.fillText(emoji, 0, 0); + + const { data } = context.getImageData(0, 0, size, size); + for (let i = 0; i < data.length; i += 4) { + if (data[i + 3]! > 0 && (data[i] !== data[i + 1] || data[i + 1] !== data[i + 2])) return true; + } + return false; +} diff --git a/component-library/src/components/custom/BccEmojiPicker/state.ts b/component-library/src/components/custom/BccEmojiPicker/state.ts new file mode 100644 index 00000000..d9aec2a6 --- /dev/null +++ b/component-library/src/components/custom/BccEmojiPicker/state.ts @@ -0,0 +1,45 @@ +import { createVNode, render, type ComponentPublicInstance } from 'vue'; + +import { getBccAppContext } from '../../../appContext'; +import type { EmojiPickerOpenOptions } from './types'; + +type PopoverApi = ComponentPublicInstance & { + show: (options: EmojiPickerOpenOptions) => Promise; + hide: () => void; + isOpenFor: (anchor: HTMLElement) => boolean; +}; + +let popover: Promise | null = null; + +// Loaded on first use, so apps that never open the picker don't download it. +function mountPopover(): Promise { + popover ??= import('./BccEmojiPickerPopover.vue').then(({ default: BccEmojiPickerPopover }) => { + const container = document.createElement('div'); + container.setAttribute('data-bcc-emoji-picker-root', ''); + document.body.appendChild(container); + + const vnode = createVNode(BccEmojiPickerPopover); + vnode.appContext = getBccAppContext(); + render(vnode, container); + return vnode.component!.exposed as PopoverApi; + }); + popover.catch(() => (popover = null)); + return popover; +} + +/** + * Opens the emoji picker next to `anchor`. Calling it again with the same anchor while the + * picker is open closes it, so it can be used directly as a button's click handler. + */ +export async function openBccEmojiPicker(options: EmojiPickerOpenOptions): Promise { + const api = await mountPopover(); + if (api.isOpenFor(options.anchor)) { + api.hide(); + return; + } + await api.show(options); +} + +export async function closeBccEmojiPicker(): Promise { + if (popover) (await popover).hide(); +} diff --git a/component-library/src/components/custom/BccEmojiPicker/types.ts b/component-library/src/components/custom/BccEmojiPicker/types.ts new file mode 100644 index 00000000..f8a63dba --- /dev/null +++ b/component-library/src/components/custom/BccEmojiPicker/types.ts @@ -0,0 +1,35 @@ +export type EmojiItem = { + emoji: string; + /** Localised name, used as the button's accessible label. */ + label: string; + /** Localised search keywords. */ + tags: string[]; +}; + +export type EmojiGroup = { + /** Emojibase group key, e.g. `smileys-emotion`. */ + key: string; + /** Localised group name. */ + label: string; + emojis: EmojiItem[]; +}; + +export type EmojiPickerProps = { + /** Locale for emoji names, search keywords and category names. Unsupported locales fall back to `en`. */ + locale?: string; + /** Placeholder for the search field. */ + searchPlaceholder?: string; + /** Shown when a search has no results. */ + noResultsText?: string; + /** Shown when the emoji data fails to load. */ + errorText?: string; +}; + +export type EmojiPickerOpenOptions = EmojiPickerProps & { + /** Element the picker is positioned next to, usually the button that opened it. */ + anchor: HTMLElement; + /** Called for each emoji the user picks. The picker stays open. */ + onSelect: (emoji: string) => void; + /** Called when the picker closes. */ + onHide?: () => void; +}; diff --git a/component-library/src/components/custom/index.ts b/component-library/src/components/custom/index.ts index 940b66ee..1a185a02 100644 --- a/component-library/src/components/custom/index.ts +++ b/component-library/src/components/custom/index.ts @@ -2,12 +2,14 @@ * Custom BCC components (not based on PrimeVue). * Add new custom components here and export them from this file. */ +export { closeBccEmojiPicker, openBccEmojiPicker } from './BccEmojiPicker/state'; export { closeBccLightbox, openBccLightbox, useLightbox, usePinchPan } from './BccLightbox/composables'; export { default as BccAppNavigation } from './BccAppNavigation/BccAppNavigation.vue'; export { default as BccBadge } from './BccBadge/BccBadge.vue'; export { default as BccCapacityIndicator } from './BccCapacityIndicator/BccCapacityIndicator.vue'; export { default as BccDialKnob } from './BccDialKnob/BccDialKnob.vue'; +export { default as BccEmojiPicker } from './BccEmojiPicker/BccEmojiPicker.vue'; export { default as BccFrame } from './BccFrame/BccFrame.vue'; export { default as BccGraphic } from './BccGraphic/BccGraphic.vue'; export { default as BccImage } from './BccImage/BccImage.vue'; @@ -25,6 +27,7 @@ export type { BccAppNavigationItem } from './BccAppNavigation/BccAppNavigation.v export type { BadgeProps } from './BccBadge/BccBadge.vue'; export type { CapacityIndicatorProps } from './BccCapacityIndicator/BccCapacityIndicator.vue'; export type { KnobProps, KnobSlots } from './BccDialKnob/BccDialKnob.vue'; +export type { EmojiGroup, EmojiItem, EmojiPickerOpenOptions, EmojiPickerProps } from './BccEmojiPicker/types'; export type { FrameProps } from './BccFrame/BccFrame.vue'; export type { GraphicProps } from './BccGraphic/BccGraphic.vue'; export type { ImageProps } from './BccImage/BccImage.vue'; diff --git a/component-library/src/components/custom/styles.css b/component-library/src/components/custom/styles.css index a5c26b8c..601203a2 100644 --- a/component-library/src/components/custom/styles.css +++ b/component-library/src/components/custom/styles.css @@ -2,6 +2,7 @@ @import './BccBadge/BccBadge.css'; @import './BccCapacityIndicator/BccCapacityIndicator.css'; @import './BccDialKnob/BccDialKnob.css'; +@import './BccEmojiPicker/BccEmojiPicker.css'; @import './BccFrame/BccFrame.css'; @import './BccGraphic/BccGraphic.css'; @import './BccImage/BccImage.css'; diff --git a/component-library/src/components/wrapped/BccInput.stories.ts b/component-library/src/components/wrapped/BccInput.stories.ts index b1046555..7d832a0b 100644 --- a/component-library/src/components/wrapped/BccInput.stories.ts +++ b/component-library/src/components/wrapped/BccInput.stories.ts @@ -1,7 +1,7 @@ import { LockIcon, MailIcon, NumbersIcon, PersonIcon, SearchIcon } from '@bcc-code/icons-vue'; import type { Meta, StoryObj } from '@storybook/vue3-vite'; -import { ref } from 'vue'; -import { BccFloatLabel } from '../../index'; +import { ref, useTemplateRef } from 'vue'; +import { BccButton, BccFloatLabel } from '../../index'; import BccInput from './BccInput.vue'; const meta: Meta = { @@ -213,3 +213,23 @@ export const FloatLabelInvalid: Story = { `, }) as unknown as Story, }; + +/** `insertAtCaret(text)` inserts text at the caret, replacing any selection, and updates `v-model`. It does nothing when `numeric`. */ +export const InsertAtCaret: Story = { + render: () => + ({ + components: { BccInput, BccButton }, + setup() { + const value = ref('Hello world'); + // In an SFC, useTemplateRef('input') infers this type from the template. + const input = useTemplateRef>('input'); + return { value, input }; + }, + template: ` +
+ + +
+ `, + }) as unknown as Story, +}; diff --git a/component-library/src/components/wrapped/BccInput.vue b/component-library/src/components/wrapped/BccInput.vue index 43e5215f..8801af3f 100644 --- a/component-library/src/components/wrapped/BccInput.vue +++ b/component-library/src/components/wrapped/BccInput.vue @@ -5,7 +5,8 @@ import BccInputIcon from 'primevue/inputicon'; import BccInputNumber, { type InputNumberProps } from 'primevue/inputnumber'; import BccInputText, { type InputTextProps } from 'primevue/inputtext'; import BccProgressSpinner from 'primevue/progressspinner'; -import { computed, useAttrs } from 'vue'; +import { computed, useAttrs, useTemplateRef } from 'vue'; +import { insertAtCaret as insertAtCaretOf } from './insertAtCaret'; defineOptions({ inheritAttrs: false }); @@ -28,6 +29,21 @@ const inputBindings = computed((): InputTextProps => { void iconRight; return { ...rest, ...attrs } as InputTextProps; }); + +export type InputMethods = { + /** Inserts text at the caret, replacing any selection, and updates v-model. Does nothing when `numeric`. */ + insertAtCaret: (text: string) => void; +}; + +const input = useTemplateRef<{ $el: HTMLInputElement }>('input'); + +const exposed: InputMethods = { + insertAtCaret: text => { + // InputNumber parses and formats its own input, so free text can't be inserted there. + if (!props.numeric && input.value) insertAtCaretOf(input.value.$el, text); + }, +}; +defineExpose(exposed);