Skip to content

Commit 0e4025b

Browse files
committed
feat(emojis): support curated emoji-mart pickerProps options
Widen EmojiPicker's `pickerProps` (emoji-mart-compatible names) with layout positions (navPosition, previewPosition, searchPosition, skinTonePosition), grid/content (perLine, categories, maxFrequentRows), and filtering + polish (exceptEmojis, emojiVersion, noCountryFlags, previewEmoji, noResultsEmoji, autoFocus, onClickOutside). All optional and backwards-compatible — defaults reproduce today's picker, and unsupported emoji-mart options still warn. Add an "Emoji Picker" playground tab to the vite example — live preview, reset-to-defaults, and all options wired — and make the settings modal body scroll so tall tabs no longer clip.
1 parent 8376d53 commit 0e4025b

24 files changed

Lines changed: 1161 additions & 77 deletions

AI.md

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -268,11 +268,28 @@ import 'stream-chat-react/css/emoji-picker.css';
268268
- Skin tone and "frequently used" are integrator-managed props on `EmojiPicker`
269269
(`skinTone`/`onSkinToneChange`, `frequentlyUsedEmoji`/`onFrequentlyUsedChange`);
270270
the SDK does not persist them. See `examples/vite/` for a localStorage example.
271-
- `pickerProps` accepts only `theme` and `style`. emoji-mart `Picker` options
272-
(`data`, `set`, `custom`, `categories`, `perLine`, `emojiVersion`, `locale`, …) are
273-
no longer supported: the type rejects them and any passed via `as` casts are
274-
ignored with a console warning. For appearance use the `--str-chat__emoji-picker-*`
275-
CSS variables.
271+
- `pickerProps` accepts a curated set of emoji-mart-compatible `Picker` options (plus
272+
`theme` and `style`):
273+
- **Layout**: `navPosition` / `previewPosition` (`'top' | 'bottom' | 'none'`),
274+
`searchPosition` (`'sticky' | 'static' | 'none'`), `skinTonePosition`
275+
(`'preview' | 'search' | 'none'`).
276+
- **Grid & content**: `perLine` (default `9`), `categories` (filter + reorder;
277+
`'frequent'` always prepends), `maxFrequentRows` (default `1`).
278+
- **Filtering & polish**: `exceptEmojis`, `emojiVersion`, `noCountryFlags`,
279+
`previewEmoji`, `noResultsEmoji`, `autoFocus`, `onClickOutside`.
280+
281+
Divergences from emoji-mart's defaults: `autoFocus` defaults to `true`; `emojiVersion`
282+
is unfiltered by default (the bundled set 15); `previewEmoji` / `noResultsEmoji` default
283+
to the SDK's placeholder / empty state; `noCountryFlags` is opt-in (no Windows
284+
auto-detect); `categories` cannot reposition `'frequent'`.
285+
286+
Not supported (rejected by the type; ignored with a console warning at runtime): image
287+
sets (`set`, `getSpritesheetURL`), `custom` emoji, `data`, `i18n` / `locale`,
288+
`dynamicWidth`, `icons`, `categoryIcons`. Sizing knobs (`emojiSize`, `emojiButtonSize`,
289+
…) are `--str-chat__emoji-picker-*` CSS variables, not props. Skin tone uses the
290+
first-class `skinTone` / `defaultSkinTone` props (not emoji-mart's `skin`). Try these
291+
live in the "Emoji Picker" settings tab of `examples/vite/`.
292+
276293
- To let users **react with any emoji** (the reaction selector's `+` button), fill
277294
`reactionOptions.extended` with the full emoji set. It also gates display — a reaction
278295
whose type isn't in `quick`/`extended` is not rendered. Load it lazily from the emojis

examples/vite/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ const EmojiPickerWithCustomOptions = (
201201
props: React.ComponentProps<typeof EmojiPicker>,
202202
) => {
203203
const { mode } = useAppSettingsSelector((state) => state.theme);
204+
const emojiPicker = useAppSettingsSelector((state) => state.emojiPicker);
204205
const [skinTone, setSkinTone] = useState(() => readStored(EMOJI_SKIN_TONE_KEY, 0));
205206
const [frequentlyUsedEmoji, setFrequentlyUsedEmoji] = useState(() =>
206207
readStored<string[]>(EMOJI_FREQUENTLY_USED_KEY, []),
@@ -220,6 +221,7 @@ const EmojiPickerWithCustomOptions = (
220221
}}
221222
pickerProps={{
222223
...props.pickerProps,
224+
...emojiPicker,
223225
theme: mode,
224226
}}
225227
skinTone={skinTone}

examples/vite/src/AppSettings/AppSettings.scss

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1010,6 +1010,8 @@
10101010
.app__settings-modal__content-stack {
10111011
display: flex;
10121012
flex-direction: column;
1013+
height: 100%;
1014+
min-height: 0;
10131015
}
10141016

10151017
.app__settings-modal__tab-header .str-chat__prompt__header__title {
@@ -1044,8 +1046,14 @@
10441046

10451047
.app__settings-modal__tab-body {
10461048
display: flex;
1049+
flex: 1 1 auto;
10471050
flex-direction: column;
10481051
gap: var(--str-chat__spacing-xl);
1052+
min-height: 0;
1053+
// Tall tabs (e.g. Emoji Picker) would otherwise clip under the modal's
1054+
// `overflow: hidden`; scroll the body while the header stays put.
1055+
overflow-y: auto;
1056+
scrollbar-width: thin;
10491057
}
10501058

10511059
.app__settings-modal__field {
@@ -1077,6 +1085,42 @@
10771085
flex-wrap: wrap;
10781086
}
10791087

1088+
.app__settings-modal__reset-row {
1089+
display: flex;
1090+
justify-content: flex-end;
1091+
}
1092+
1093+
// Emoji Picker tab: controls on the left, a live preview on the right that stays in
1094+
// view (sticky) while the controls scroll. Wraps to a single column when too narrow.
1095+
.app__emoji-settings {
1096+
display: flex;
1097+
flex-wrap: wrap;
1098+
align-items: flex-start;
1099+
gap: var(--str-chat__spacing-xl);
1100+
}
1101+
1102+
.app__emoji-settings__controls {
1103+
display: flex;
1104+
flex: 1 1 300px;
1105+
flex-direction: column;
1106+
gap: var(--str-chat__spacing-xl);
1107+
min-width: 0;
1108+
}
1109+
1110+
.app__emoji-settings__preview {
1111+
display: flex;
1112+
flex: 0 0 auto;
1113+
flex-direction: column;
1114+
gap: var(--str-chat__spacing-xs);
1115+
position: sticky;
1116+
top: 0;
1117+
1118+
.str-chat__emoji-picker {
1119+
border: 1px solid var(--str-chat__border-core-default);
1120+
border-radius: 12px;
1121+
}
1122+
}
1123+
10801124
.app__settings-modal__action-list {
10811125
display: flex;
10821126
flex-direction: column;

examples/vite/src/AppSettings/AppSettings.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818

1919
import { ActionsMenu } from './ActionsMenu';
2020
import { ChannelDetailTab } from './tabs/ChannelDetail';
21+
import { EmojiPickerTab } from './tabs/EmojiPicker';
2122
import { GeneralTab } from './tabs/General';
2223
import { MessageActionsTab } from './tabs/MessageActions';
2324
import { NotificationsTab } from './tabs/Notifications';
@@ -35,6 +36,7 @@ import clsx from 'clsx';
3536

3637
type TabId =
3738
| 'channelDetail'
39+
| 'emojiPicker'
3840
| 'general'
3941
| 'messageActions'
4042
| 'notifications'
@@ -74,6 +76,12 @@ const settingsSectionConfig: SettingsSectionConfig[] = [
7476
},
7577
{ Content: SidebarTab, Icon: IconSidebar, id: 'sidebar', title: 'Sidebar' },
7678
{ Content: ReactionsTab, Icon: IconEmoji, id: 'reactions', title: 'Reactions' },
79+
{
80+
Content: EmojiPickerTab,
81+
Icon: IconEmoji,
82+
id: 'emojiPicker',
83+
title: 'Emoji Picker',
84+
},
7785
];
7886

7987
const createSettingsNavButton = ({

examples/vite/src/AppSettings/state.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,29 @@ export type ChatViewSettingsState = {
1111
iconOnly: boolean;
1212
};
1313

14+
export type EmojiPickerSettingsState = {
15+
autoFocus: boolean;
16+
maxFrequentRows: number;
17+
navPosition: 'top' | 'bottom' | 'none';
18+
noCountryFlags: boolean;
19+
perLine: number;
20+
previewPosition: 'top' | 'bottom' | 'none';
21+
searchPosition: 'sticky' | 'static' | 'none';
22+
skinTonePosition: 'preview' | 'search' | 'none';
23+
};
24+
25+
// Mirrors the SDK's EmojiPicker defaults; the settings tab resets to this.
26+
export const DEFAULT_EMOJI_PICKER_SETTINGS: EmojiPickerSettingsState = {
27+
autoFocus: true,
28+
maxFrequentRows: 1,
29+
navPosition: 'top',
30+
noCountryFlags: false,
31+
perLine: 9,
32+
previewPosition: 'bottom',
33+
searchPosition: 'sticky',
34+
skinTonePosition: 'preview',
35+
};
36+
1437
export type ThemeSettingsState = {
1538
direction: 'ltr' | 'rtl';
1639
mode: 'dark' | 'light';
@@ -72,6 +95,7 @@ export type MessageListSettingsState = {
7295
export type AppSettingsState = {
7396
channelDetail: ChannelDetailSettingsState;
7497
chatView: ChatViewSettingsState;
98+
emojiPicker: EmojiPickerSettingsState;
7599
messageActions: MessageActionsSettingsState;
76100
messageList: MessageListSettingsState;
77101
notifications: NotificationsSettingsState;
@@ -116,6 +140,7 @@ const defaultAppSettingsState: AppSettingsState = {
116140
chatView: {
117141
iconOnly: true,
118142
},
143+
emojiPicker: DEFAULT_EMOJI_PICKER_SETTINGS,
119144
messageActions: {
120145
customMessageActions: {
121146
delete: {
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import { useState } from 'react';
2+
import { Button } from 'stream-chat-react';
3+
import { EmojiPickerPanel, type EmojiSelection } from 'stream-chat-react/emojis';
4+
import {
5+
appSettingsStore,
6+
DEFAULT_EMOJI_PICKER_SETTINGS,
7+
type EmojiPickerSettingsState,
8+
useAppSettingsState,
9+
} from '../../state';
10+
import {
11+
SettingsTabBody,
12+
SettingsTabLayoutHeader,
13+
} from '../SettingsTabLayoutComponents.tsx';
14+
15+
type EmojiPickerTabProps = {
16+
close: () => void;
17+
};
18+
19+
const OPTION_BUTTON_CLASS =
20+
'app__settings-modal__option-button str-chat__button--outline str-chat__button--secondary str-chat__button--size-sm';
21+
22+
const update = (patch: Partial<EmojiPickerSettingsState>) => {
23+
const current = appSettingsStore.getLatestValue().emojiPicker;
24+
appSettingsStore.partialNext({ emojiPicker: { ...current, ...patch } });
25+
};
26+
27+
type FieldProps<T extends string | number | boolean> = {
28+
label: string;
29+
onSelect: (value: T) => void;
30+
options: { label: string; value: T }[];
31+
value: T;
32+
};
33+
34+
function Field<T extends string | number | boolean>({
35+
label,
36+
onSelect,
37+
options,
38+
value,
39+
}: FieldProps<T>) {
40+
return (
41+
<div className='app__settings-modal__field'>
42+
<div className='app__settings-modal__field-label'>{label}</div>
43+
<div className='app__settings-modal__options-row'>
44+
{options.map((option) => (
45+
<Button
46+
aria-pressed={value === option.value}
47+
className={OPTION_BUTTON_CLASS}
48+
key={String(option.value)}
49+
onClick={() => onSelect(option.value)}
50+
>
51+
{option.label}
52+
</Button>
53+
))}
54+
</div>
55+
</div>
56+
);
57+
}
58+
59+
const numberOptions = (values: number[]) =>
60+
values.map((value) => ({ label: String(value), value }));
61+
62+
/**
63+
* Always-open picker wired to the current settings, so tweaks show instantly without
64+
* opening the composer. Skin tone and frequently-used are local to the preview —
65+
* selecting an emoji here feeds the "frequently used" row so `maxFrequentRows` can be
66+
* exercised too.
67+
*/
68+
const EmojiPickerPreview = ({ options }: { options: EmojiPickerSettingsState }) => {
69+
const [skinTone, setSkinTone] = useState(0);
70+
const [frequentlyUsedIds, setFrequentlyUsedIds] = useState<string[]>([]);
71+
72+
return (
73+
<EmojiPickerPanel
74+
frequentlyUsedIds={frequentlyUsedIds}
75+
onEmojiSelect={(emoji: EmojiSelection) =>
76+
setFrequentlyUsedIds((ids) => [emoji.id, ...ids.filter((id) => id !== emoji.id)])
77+
}
78+
onSkinToneChange={setSkinTone}
79+
options={{ ...options, exceptEmojis: [] }}
80+
skinToneIndex={skinTone}
81+
/>
82+
);
83+
};
84+
85+
/**
86+
* Playground for the built-in EmojiPicker's `pickerProps`. Each control writes to the
87+
* app settings store; the live preview (and the composer's picker via
88+
* `EmojiPickerWithCustomOptions`) reflect the change instantly.
89+
*/
90+
export const EmojiPickerTab = ({ close }: EmojiPickerTabProps) => {
91+
const { emojiPicker } = useAppSettingsState();
92+
const atDefaults = (
93+
Object.keys(DEFAULT_EMOJI_PICKER_SETTINGS) as (keyof EmojiPickerSettingsState)[]
94+
).every((key) => emojiPicker[key] === DEFAULT_EMOJI_PICKER_SETTINGS[key]);
95+
96+
return (
97+
<div className='app__settings-modal__content-stack'>
98+
<SettingsTabLayoutHeader
99+
close={close}
100+
description='Configure the built-in EmojiPicker via pickerProps. The live preview and the composer’s picker both update instantly.'
101+
title='Emoji Picker'
102+
/>
103+
104+
<SettingsTabBody>
105+
<div className='app__emoji-settings'>
106+
<div className='app__emoji-settings__controls'>
107+
<div className='app__settings-modal__reset-row'>
108+
<Button
109+
className='str-chat__button--outline str-chat__button--secondary str-chat__button--size-sm'
110+
disabled={atDefaults}
111+
onClick={() =>
112+
appSettingsStore.partialNext({
113+
emojiPicker: DEFAULT_EMOJI_PICKER_SETTINGS,
114+
})
115+
}
116+
>
117+
Reset to defaults
118+
</Button>
119+
</div>
120+
<Field<EmojiPickerSettingsState['navPosition']>
121+
label='Navigation position'
122+
onSelect={(navPosition) => update({ navPosition })}
123+
options={[
124+
{ label: 'Top', value: 'top' },
125+
{ label: 'Bottom', value: 'bottom' },
126+
{ label: 'None', value: 'none' },
127+
]}
128+
value={emojiPicker.navPosition}
129+
/>
130+
<Field<EmojiPickerSettingsState['previewPosition']>
131+
label='Preview position'
132+
onSelect={(previewPosition) => update({ previewPosition })}
133+
options={[
134+
{ label: 'Top', value: 'top' },
135+
{ label: 'Bottom', value: 'bottom' },
136+
{ label: 'None', value: 'none' },
137+
]}
138+
value={emojiPicker.previewPosition}
139+
/>
140+
<Field<EmojiPickerSettingsState['searchPosition']>
141+
label='Search position'
142+
onSelect={(searchPosition) => update({ searchPosition })}
143+
options={[
144+
{ label: 'Sticky', value: 'sticky' },
145+
{ label: 'Static', value: 'static' },
146+
{ label: 'None', value: 'none' },
147+
]}
148+
value={emojiPicker.searchPosition}
149+
/>
150+
<Field<EmojiPickerSettingsState['skinTonePosition']>
151+
label='Skin-tone position'
152+
onSelect={(skinTonePosition) => update({ skinTonePosition })}
153+
options={[
154+
{ label: 'Preview', value: 'preview' },
155+
{ label: 'Search', value: 'search' },
156+
{ label: 'None', value: 'none' },
157+
]}
158+
value={emojiPicker.skinTonePosition}
159+
/>
160+
<Field<number>
161+
label='Emoji per line'
162+
onSelect={(perLine) => update({ perLine })}
163+
options={numberOptions([7, 8, 9, 10])}
164+
value={emojiPicker.perLine}
165+
/>
166+
<Field<number>
167+
label='Frequently-used rows'
168+
onSelect={(maxFrequentRows) => update({ maxFrequentRows })}
169+
options={numberOptions([1, 2, 3, 4])}
170+
value={emojiPicker.maxFrequentRows}
171+
/>
172+
<Field<boolean>
173+
label='Auto-focus search'
174+
onSelect={(autoFocus) => update({ autoFocus })}
175+
options={[
176+
{ label: 'On', value: true },
177+
{ label: 'Off', value: false },
178+
]}
179+
value={emojiPicker.autoFocus}
180+
/>
181+
<Field<boolean>
182+
label='Country flags'
183+
onSelect={(noCountryFlags) => update({ noCountryFlags })}
184+
options={[
185+
{ label: 'Show', value: false },
186+
{ label: 'Hide', value: true },
187+
]}
188+
value={emojiPicker.noCountryFlags}
189+
/>
190+
</div>
191+
<div className='app__emoji-settings__preview'>
192+
<div className='app__settings-modal__field-label'>Live preview</div>
193+
<EmojiPickerPreview options={emojiPicker} />
194+
</div>
195+
</div>
196+
</SettingsTabBody>
197+
</div>
198+
);
199+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './EmojiPickerTab';

0 commit comments

Comments
 (0)