Skip to content

Commit 737a03a

Browse files
committed
feat(emojis): show emoji shortcode in preview and keep frequently-used to one row
Polish two details of the built-in emoji picker: - The footer preview now shows the emoji's `:shortcode:` beneath its name (the same token that drives `:` autocomplete), so the preview doubles as a hint. - The "frequently used" section is capped to a single row: its ids are sliced to one row's worth (resolveFrequentlyUsedEmoji) and the frequent grid is pinned to a fixed column count, so it no longer wraps to extra rows as more emoji are used. Also adds EmojiButton skin-tone tests documenting that skin tone applies only to emoji that have skin variants (hands/people) — not the faces shown by default — which is why changing the tone appears to have no effect there.
1 parent a20dd46 commit 737a03a

7 files changed

Lines changed: 220 additions & 5 deletions

File tree

src/plugins/Emojis/components/EmojiPickerPanel.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { CategoryNav } from './CategoryNav';
44
import { EmojiButton } from './EmojiButton';
55
import { EmojiGrid, type EmojiGridHandle, type EmojiPickerCategory } from './EmojiGrid';
66
import { EMOJI_CATEGORY_META } from './categories';
7+
import { resolveFrequentlyUsedEmoji } from './frequentlyUsed';
78
import { EmptyResults } from './EmptyResults';
89
import { PreviewPane } from './PreviewPane';
910
import { SearchInput } from './SearchInput';
@@ -101,7 +102,9 @@ export const EmojiPickerPanel = ({
101102
const categories = useMemo<EmojiPickerCategory[]>(() => {
102103
if (!data || !frequentlyUsedIds.length) return baseCategories;
103104
const frequent: EmojiPickerCategory = {
104-
emojis: frequentlyUsedIds.map((id) => data.emojis[id]).filter(Boolean),
105+
// Capped to a single row (see resolveFrequentlyUsedEmoji + the frequent-section
106+
// CSS) so the section never grows to multiple rows as more emoji are used.
107+
emojis: resolveFrequentlyUsedEmoji(data, frequentlyUsedIds),
105108
id: 'frequent',
106109
label: t(EMOJI_CATEGORY_META.frequent.labelKey),
107110
};

src/plugins/Emojis/components/PreviewPane.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ export type PreviewPaneProps = {
77
};
88

99
/**
10-
* Footer preview of the hovered/focused emoji: a large glyph plus its name. Falls back
11-
* to a "Pick an emoji…" placeholder (like emoji-mart) so the footer is never empty.
10+
* Footer preview of the hovered/focused emoji: a large glyph, its name, and its
11+
* `:shortcode:` (so users learn the token that drives `:` autocomplete). Falls back to
12+
* a "Pick an emoji…" placeholder (like emoji-mart) so the footer is never empty.
1213
* Receives the previewed emoji as a prop (kept out of context) so hovering does not
1314
* re-render the emoji grid.
1415
*/
@@ -23,8 +24,13 @@ export const PreviewPane = ({ emoji }: PreviewPaneProps) => {
2324
? (emoji.skins[skinToneIndex]?.native ?? emoji.skins[0]?.native ?? '')
2425
: '☝️'}
2526
</span>
26-
<span className='str-chat__emoji-picker__preview-name'>
27-
{emoji ? emoji.name : t('Pick an emoji…')}
27+
<span className='str-chat__emoji-picker__preview-text'>
28+
<span className='str-chat__emoji-picker__preview-name'>
29+
{emoji ? emoji.name : t('Pick an emoji…')}
30+
</span>
31+
{emoji ? (
32+
<span className='str-chat__emoji-picker__preview-shortcode'>{`:${emoji.id}:`}</span>
33+
) : null}
2834
</span>
2935
</div>
3036
);
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { render, screen } from '@testing-library/react';
2+
import { EmojiButton } from '../EmojiButton';
3+
import { EmojiPickerProvider } from '../../context/EmojiPickerContext';
4+
import type { EmojiDataEmoji } from '../../data';
5+
6+
// A skin-tone-capable emoji (6 skins) and a plain one (1 skin) — mirrors the dataset,
7+
// where only ~305 of ~1870 emoji have skin variants.
8+
const wave: EmojiDataEmoji = {
9+
id: 'wave',
10+
keywords: ['hand'],
11+
name: 'Waving Hand',
12+
skins: [
13+
{ native: '👋', unified: '1f44b' },
14+
{ native: '👋🏻', unified: '1f44b-1f3fb' },
15+
{ native: '👋🏼', unified: '1f44b-1f3fc' },
16+
{ native: '👋🏽', unified: '1f44b-1f3fd' },
17+
{ native: '👋🏾', unified: '1f44b-1f3fe' },
18+
{ native: '👋🏿', unified: '1f44b-1f3ff' },
19+
],
20+
version: 1,
21+
};
22+
23+
const grinning: EmojiDataEmoji = {
24+
id: 'grinning',
25+
keywords: ['face'],
26+
name: 'Grinning',
27+
skins: [{ native: '😀', unified: '1f600' }],
28+
version: 1,
29+
};
30+
31+
const renderButton = (emoji: EmojiDataEmoji, skinToneIndex: number) =>
32+
render(
33+
<EmojiPickerProvider
34+
value={{ onSelectEmoji: vi.fn(), setPreviewedEmoji: vi.fn(), skinToneIndex }}
35+
>
36+
<EmojiButton emoji={emoji} />
37+
</EmojiPickerProvider>,
38+
);
39+
40+
describe('EmojiButton skin tone', () => {
41+
it('renders the default glyph at skin tone 0', () => {
42+
renderButton(wave, 0);
43+
expect(screen.getByRole('button', { name: 'Waving Hand' })).toHaveTextContent('👋');
44+
});
45+
46+
it('renders the toned glyph for a skin-tone-capable emoji', () => {
47+
renderButton(wave, 5);
48+
expect(screen.getByRole('button', { name: 'Waving Hand' })).toHaveTextContent('👋🏿');
49+
});
50+
51+
it('updates the glyph when the active skin tone changes (memo does not block context)', () => {
52+
const { rerender } = renderButton(wave, 0);
53+
expect(screen.getByRole('button', { name: 'Waving Hand' })).toHaveTextContent('👋');
54+
55+
rerender(
56+
<EmojiPickerProvider
57+
value={{ onSelectEmoji: vi.fn(), setPreviewedEmoji: vi.fn(), skinToneIndex: 3 }}
58+
>
59+
<EmojiButton emoji={wave} />
60+
</EmojiPickerProvider>,
61+
);
62+
expect(screen.getByRole('button', { name: 'Waving Hand' })).toHaveTextContent('👋🏽');
63+
});
64+
65+
it('leaves an emoji with no skin variants unchanged at any tone (why smileys never change)', () => {
66+
renderButton(grinning, 5);
67+
// Falls back to skins[0] — this is why changing skin tone has no visible effect on
68+
// the faces shown by default; only hand/person emoji carry skin variants.
69+
expect(screen.getByRole('button', { name: 'Grinning' })).toHaveTextContent('😀');
70+
});
71+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { render, screen } from '@testing-library/react';
2+
3+
vi.mock('../../../../context', () => ({
4+
useTranslationContext: () => ({ t: (key: string) => key }),
5+
}));
6+
7+
import { PreviewPane } from '../PreviewPane';
8+
import { EmojiPickerProvider } from '../../context/EmojiPickerContext';
9+
import type { EmojiDataEmoji } from '../../data';
10+
11+
const smile: EmojiDataEmoji = {
12+
id: 'smile',
13+
keywords: ['happy'],
14+
name: 'Smiling Face',
15+
skins: [{ native: '😄', unified: '1f604' }],
16+
version: 1,
17+
};
18+
19+
const renderPreview = (emoji: EmojiDataEmoji | null, skinToneIndex = 0) =>
20+
render(
21+
<EmojiPickerProvider
22+
value={{ onSelectEmoji: vi.fn(), setPreviewedEmoji: vi.fn(), skinToneIndex }}
23+
>
24+
<PreviewPane emoji={emoji} />
25+
</EmojiPickerProvider>,
26+
);
27+
28+
describe('PreviewPane', () => {
29+
it('shows the emoji name and its shortcode when an emoji is previewed', () => {
30+
renderPreview(smile);
31+
32+
expect(screen.getByText('Smiling Face')).toBeInTheDocument();
33+
// The shortcode (`:id:`) must be visible so users learn the autocomplete token.
34+
expect(screen.getByText(':smile:')).toBeInTheDocument();
35+
});
36+
37+
it('shows the placeholder and no shortcode when nothing is previewed', () => {
38+
renderPreview(null);
39+
40+
expect(screen.getByText('Pick an emoji…')).toBeInTheDocument();
41+
expect(screen.queryByText(/^:.+:$/)).not.toBeInTheDocument();
42+
});
43+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { FREQUENTLY_USED_LIMIT, resolveFrequentlyUsedEmoji } from '../frequentlyUsed';
2+
import type { EmojiData, EmojiDataEmoji } from '../../data';
3+
4+
const makeEmoji = (id: string): EmojiDataEmoji => ({
5+
id,
6+
keywords: [],
7+
name: id,
8+
skins: [{ native: id, unified: id }],
9+
version: 1,
10+
});
11+
12+
const data = {
13+
aliases: {},
14+
categories: [],
15+
emojis: Object.fromEntries(
16+
Array.from({ length: 30 }, (_, i) => `e${i}`).map((id) => [id, makeEmoji(id)]),
17+
),
18+
} as unknown as EmojiData;
19+
20+
describe('resolveFrequentlyUsedEmoji', () => {
21+
it('caps the frequently-used list to a single row so it never wraps', () => {
22+
const ids = Array.from({ length: 30 }, (_, i) => `e${i}`);
23+
const result = resolveFrequentlyUsedEmoji(data, ids);
24+
25+
expect(result).toHaveLength(FREQUENTLY_USED_LIMIT);
26+
});
27+
28+
it('preserves most-recent-first order', () => {
29+
const result = resolveFrequentlyUsedEmoji(data, ['e2', 'e0', 'e1']);
30+
31+
expect(result.map((emoji) => emoji.id)).toEqual(['e2', 'e0', 'e1']);
32+
});
33+
34+
it('drops ids missing from the dataset', () => {
35+
const result = resolveFrequentlyUsedEmoji(data, ['e0', 'does-not-exist', 'e1']);
36+
37+
expect(result.map((emoji) => emoji.id)).toEqual(['e0', 'e1']);
38+
});
39+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { EmojiData, EmojiDataEmoji } from '../data';
2+
3+
/**
4+
* How many recently-used emoji the "frequently used" section shows. Matches the
5+
* default grid's column count, so the section stays a single row.
6+
*
7+
* Kept in sync with the fixed column count in EmojiPicker.scss
8+
* (`[data-category-id='frequent'] .str-chat__emoji-picker__category-emojis`): the
9+
* slice caps the item count and the CSS pins the columns, together guaranteeing one
10+
* row regardless of how many emoji have been used.
11+
*/
12+
export const FREQUENTLY_USED_LIMIT = 9;
13+
14+
/**
15+
* Resolves an ordered (most-recent-first) list of emoji ids to dataset entries for the
16+
* "frequently used" section: drops ids missing from the dataset and caps the result to
17+
* a single row (`FREQUENTLY_USED_LIMIT`).
18+
*/
19+
export const resolveFrequentlyUsedEmoji = (
20+
data: EmojiData,
21+
frequentlyUsedIds: string[],
22+
limit = FREQUENTLY_USED_LIMIT,
23+
): EmojiDataEmoji[] =>
24+
frequentlyUsedIds
25+
.map((id) => data.emojis[id])
26+
.filter(Boolean)
27+
.slice(0, limit);

src/plugins/Emojis/styling/EmojiPicker.scss

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,15 @@ $emoji-picker-border-radius: 12px;
188188
gap: var(--str-chat__spacing-xs);
189189
padding-block-end: var(--str-chat__spacing-xs);
190190
}
191+
192+
// Pin the "frequently used" section to a single row. The panel caps its ids to
193+
// FREQUENTLY_USED_LIMIT (see frequentlyUsed.ts) and these fixed columns match the
194+
// default grid width, so it fills one aligned row and never wraps to a second as
195+
// more emoji are used. (Uses the literal child class, not `&-emojis`, so the
196+
// combinator doesn't re-expand the whole parent chain.)
197+
&[data-category-id='frequent'] > .str-chat__emoji-picker__category-emojis {
198+
grid-template-columns: repeat(9, minmax(0, 1fr));
199+
}
191200
}
192201

193202
&__emoji {
@@ -228,15 +237,32 @@ $emoji-picker-border-radius: 12px;
228237
min-inline-size: 0;
229238

230239
&-emoji {
240+
flex: none;
231241
font-size: 1.5rem;
232242
line-height: 1;
233243
}
234244

245+
// Name (prominent) stacked over the `:shortcode:` (muted), both truncated.
246+
&-text {
247+
display: flex;
248+
flex-direction: column;
249+
min-inline-size: 0;
250+
line-height: 1.2;
251+
}
252+
235253
&-name {
254+
overflow: hidden;
255+
white-space: nowrap;
256+
text-overflow: ellipsis;
257+
font-size: 0.875rem;
258+
}
259+
260+
&-shortcode {
236261
overflow: hidden;
237262
color: var(--str-chat__emoji-picker-secondary-text-color);
238263
white-space: nowrap;
239264
text-overflow: ellipsis;
265+
font-size: 0.8125rem;
240266
}
241267
}
242268

0 commit comments

Comments
 (0)