@astryxdesign/lab 0.7.2-canary.58d3353 → 0.7.2-canary.59c4ef7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */
4
+ export default {
5
+ type: 'block',
6
+ name: 'In a popover',
7
+ displayName: 'In a popover',
8
+ description:
9
+ 'Open the picker from an emoji button in a Popover with no padding. Leave the Popover’s autofocus on: it lands in the search field, so typing searches right away.',
10
+ exampleFor: 'EmojiPicker',
11
+ isReady: true,
12
+ aspectRatio: 4 / 3,
13
+ componentsUsed: ['EmojiPicker', 'Popover', 'IconButton', 'Text', 'HStack'],
14
+ };
@@ -0,0 +1,48 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @input Controlled popover state and the text the picks build
7
+ * @output An emoji button opening the picker in a Popover; the Popover's own
8
+ * autofocus lands in the picker's search field
9
+ * @position Copyable Lab EmojiPicker example
10
+ */
11
+
12
+ import {useState} from 'react';
13
+ import {HStack} from '@astryxdesign/core/Layout';
14
+ import {IconButton} from '@astryxdesign/core/IconButton';
15
+ import {Popover} from '@astryxdesign/core/Popover';
16
+ import {Text} from '@astryxdesign/core/Text';
17
+ import {EmojiPicker} from '@astryxdesign/lab';
18
+
19
+ export default function EmojiPickerInPopover() {
20
+ const [isOpen, setIsOpen] = useState(false);
21
+ const [text, setText] = useState('');
22
+
23
+ return (
24
+ <HStack gap={2} align="center">
25
+ <Popover
26
+ isOpen={isOpen}
27
+ onOpenChange={setIsOpen}
28
+ label="Add emoji"
29
+ padding={0}
30
+ width={352}
31
+ content={
32
+ <EmojiPicker
33
+ onSelect={emoji => {
34
+ setText(current => current + emoji);
35
+ setIsOpen(false);
36
+ }}
37
+ />
38
+ }>
39
+ <IconButton
40
+ label="Add emoji"
41
+ variant="secondary"
42
+ icon={<span aria-hidden="true">🙂</span>}
43
+ />
44
+ </Popover>
45
+ <Text type="large">{text === '' ? 'Pick some emoji' : text}</Text>
46
+ </HStack>
47
+ );
48
+ }
@@ -0,0 +1,15 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */
4
+ export default {
5
+ type: 'block',
6
+ name: 'Inline picker',
7
+ displayName: 'Inline picker',
8
+ description:
9
+ 'An emoji picker inline in a Card: search by name or keyword, move with the arrow keys, and pick; the last pick shows above it.',
10
+ exampleFor: 'EmojiPicker',
11
+ isReady: true,
12
+ isShowcase: true,
13
+ aspectRatio: 1,
14
+ componentsUsed: ['EmojiPicker', 'Card', 'HStack', 'VStack', 'Text'],
15
+ };
@@ -0,0 +1,33 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @input The picked emoji, kept in local state
7
+ * @output An inline emoji picker in a Card, naming the last pick
8
+ * @position Lab EmojiPicker's docsite showcase and copyable CLI block
9
+ */
10
+
11
+ import {useState} from 'react';
12
+ import {Card} from '@astryxdesign/core/Card';
13
+ import {HStack, VStack} from '@astryxdesign/core/Layout';
14
+ import {Text} from '@astryxdesign/core/Text';
15
+ import {EmojiPicker, type EmojiOption} from '@astryxdesign/lab';
16
+
17
+ export default function EmojiPickerShowcase() {
18
+ const [picked, setPicked] = useState<EmojiOption | null>(null);
19
+
20
+ return (
21
+ <VStack gap={3} width={352}>
22
+ <HStack gap={2} align="center">
23
+ <Text type="label">Picked:</Text>
24
+ <Text type="body" color="secondary">
25
+ {picked == null ? 'Nothing yet' : `${picked.emoji} ${picked.name}`}
26
+ </Text>
27
+ </HStack>
28
+ <Card padding={0} width="100%">
29
+ <EmojiPicker onSelect={(_, option) => setPicked(option)} />
30
+ </Card>
31
+ </VStack>
32
+ );
33
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @file EmojiPicker.tsx
3
+ * @input Uses React, StyleX, theme tokens, useGridFocus, useTranslator,
4
+ * TextInput, ScrollableArea, Text, Spinner, Icon, and the lazily loaded CLDR
5
+ * emoji set (emojiData.ts)
6
+ * @output Exports EmojiPicker, EmojiPickerProps, EmojiOption, EmojiCategory,
7
+ * DEFAULT_FREQUENT_EMOJIS, and a persistent search-status announcement
8
+ * @position Standalone emoji panel: a search field over every emoji by CLDR
9
+ * group. Hosts place it inline or inside a Popover; CommentComposer uses it
10
+ * for its emoji button.
11
+ *
12
+ * The full emoji set is the largest part of the picker, so it loads the first
13
+ * time a picker mounts rather than with the page. Each group is a WAI-ARIA
14
+ * grid of eight columns; one roving tab stop crosses every group through the
15
+ * shared `useGridFocus` keyboard model. Enter in the search field picks the
16
+ * first match, and a footer names the emoji under the pointer or focus.
17
+ *
18
+ * SYNC: When modified, update these files to stay in sync:
19
+ * - /packages/lab/src/EmojiPicker/index.ts (exports)
20
+ * - /packages/lab/src/EmojiPicker/EmojiPicker.doc.mjs
21
+ * - /packages/lab/blocks/EmojiPickerShowcase.tsx
22
+ * - /packages/core/locales/en.json (@astryx.emojiPicker.*)
23
+ */
24
+ import { type Ref } from 'react';
25
+ import type { BaseProps, SizeValue } from '@astryxdesign/core';
26
+ export interface EmojiOption {
27
+ /** The emoji character, e.g. "🎉". */
28
+ emoji: string;
29
+ /** Its name, used for search and as its accessible label, e.g. "Party popper". */
30
+ name: string;
31
+ /** Other words search should match, e.g. ["celebration", "tada"]. */
32
+ keywords?: readonly string[];
33
+ }
34
+ export interface EmojiCategory {
35
+ /** The group's heading, e.g. "Smileys & emotion". */
36
+ name: string;
37
+ emojis: readonly EmojiOption[];
38
+ }
39
+ /** The emoji listed first, before the groups, when the host passes none. */
40
+ export declare const DEFAULT_FREQUENT_EMOJIS: readonly EmojiOption[];
41
+ export interface EmojiPickerProps extends Omit<BaseProps<HTMLDivElement>, 'onSelect'> {
42
+ /** Ref forwarded to the root element. */
43
+ ref?: Ref<HTMLDivElement>;
44
+ /** Called with the picked emoji character and its option. */
45
+ onSelect: (emoji: string, option: EmojiOption) => void;
46
+ /**
47
+ * Emoji listed first, under "Frequently used": the host's own recent or
48
+ * favorite emoji. Pass an empty array to leave the row out.
49
+ * @default DEFAULT_FREQUENT_EMOJIS
50
+ */
51
+ frequentEmojis?: readonly EmojiOption[];
52
+ /**
53
+ * The emoji to offer, by group. Omit it to load the bundled set: every
54
+ * emoji through Emoji 15 with English CLDR names, loaded the first time a
55
+ * picker mounts. Pass your own groups for a localized or custom set.
56
+ */
57
+ categories?: readonly EmojiCategory[];
58
+ /**
59
+ * Height of the scrolling emoji list.
60
+ * @default 'calc(var(--spacing-8) * 10)'
61
+ */
62
+ height?: SizeValue;
63
+ /**
64
+ * Focuses the search field when the picker mounts, for a picker shown
65
+ * inline or in a Dialog. Inside a Popover, leave the Popover's own autofocus
66
+ * on instead: it focuses the search field once the Popover shows.
67
+ * @default false
68
+ */
69
+ hasAutoFocus?: boolean;
70
+ /**
71
+ * Accessible name of the emoji list. Defaults to the localized "Emoji".
72
+ */
73
+ label?: string;
74
+ /**
75
+ * Placeholder and hidden label of the search field. Defaults to the
76
+ * localized "Search emoji".
77
+ */
78
+ searchLabel?: string;
79
+ }
80
+ /**
81
+ * A standalone emoji panel: a search field over every emoji by group, with
82
+ * the host's frequent emoji first. Picking one calls `onSelect`; the picker
83
+ * keeps no selection of its own.
84
+ *
85
+ * @example
86
+ * ```
87
+ * <Card padding={0} width={352}>
88
+ * <EmojiPicker onSelect={emoji => insert(emoji)} />
89
+ * </Card>
90
+ * ```
91
+ */
92
+ export declare function EmojiPicker({ onSelect, frequentEmojis, categories: categoriesProp, height, hasAutoFocus, label: labelProp, searchLabel: searchLabelProp, xstyle, className, style, ref, ...rest }: EmojiPickerProps): import("react").JSX.Element;
93
+ export declare namespace EmojiPicker {
94
+ var displayName: string;
95
+ }
96
+ //# sourceMappingURL=EmojiPicker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"EmojiPicker.d.ts","sourceRoot":"","sources":["../../src/EmojiPicker/EmojiPicker.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAOL,KAAK,GAAG,EACT,MAAM,OAAO,CAAC;AAUf,OAAO,KAAK,EAAC,SAAS,EAAE,SAAS,EAAC,MAAM,oBAAoB,CAAC;AAiB7D,MAAM,WAAW,WAAW;IAC1B,sCAAsC;IACtC,KAAK,EAAE,MAAM,CAAC;IACd,kFAAkF;IAClF,IAAI,EAAE,MAAM,CAAC;IACb,qEAAqE;IACrE,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC9B;AAED,MAAM,WAAW,aAAa;IAC5B,qDAAqD;IACrD,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,SAAS,WAAW,EAAE,CAAC;CAChC;AAED,4EAA4E;AAC5E,eAAO,MAAM,uBAAuB,EAAE,SAAS,WAAW,EASxD,CAAC;AAEH,MAAM,WAAW,gBAAiB,SAAQ,IAAI,CAC5C,SAAS,CAAC,cAAc,CAAC,EACzB,UAAU,CACX;IACC,yCAAyC;IACzC,GAAG,CAAC,EAAE,GAAG,CAAC,cAAc,CAAC,CAAC;IAE1B,6DAA6D;IAC7D,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,KAAK,IAAI,CAAC;IAEvD;;;;OAIG;IACH,cAAc,CAAC,EAAE,SAAS,WAAW,EAAE,CAAC;IAExC;;;;OAIG;IACH,UAAU,CAAC,EAAE,SAAS,aAAa,EAAE,CAAC;IAEtC;;;OAGG;IACH,MAAM,CAAC,EAAE,SAAS,CAAC;IAEnB;;;;;OAKG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IAEvB;;OAEG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAuKD;;;;;;;;;;;GAWG;AACH,wBAAgB,WAAW,CAAC,EAC1B,QAAQ,EACR,cAAwC,EACxC,UAAU,EAAE,cAAc,EAC1B,MAA4B,EAC5B,YAAoB,EACpB,KAAK,EAAE,SAAS,EAChB,WAAW,EAAE,eAAe,EAC5B,MAAM,EACN,SAAS,EACT,KAAK,EACL,GAAG,EACH,GAAG,IAAI,EACR,EAAE,gBAAgB,+BAiPlB;yBA9Pe,WAAW"}
@@ -0,0 +1,451 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file EmojiPicker.tsx
7
+ * @input Uses React, StyleX, theme tokens, useGridFocus, useTranslator,
8
+ * TextInput, ScrollableArea, Text, Spinner, Icon, and the lazily loaded CLDR
9
+ * emoji set (emojiData.ts)
10
+ * @output Exports EmojiPicker, EmojiPickerProps, EmojiOption, EmojiCategory,
11
+ * DEFAULT_FREQUENT_EMOJIS, and a persistent search-status announcement
12
+ * @position Standalone emoji panel: a search field over every emoji by CLDR
13
+ * group. Hosts place it inline or inside a Popover; CommentComposer uses it
14
+ * for its emoji button.
15
+ *
16
+ * The full emoji set is the largest part of the picker, so it loads the first
17
+ * time a picker mounts rather than with the page. Each group is a WAI-ARIA
18
+ * grid of eight columns; one roving tab stop crosses every group through the
19
+ * shared `useGridFocus` keyboard model. Enter in the search field picks the
20
+ * first match, and a footer names the emoji under the pointer or focus.
21
+ *
22
+ * SYNC: When modified, update these files to stay in sync:
23
+ * - /packages/lab/src/EmojiPicker/index.ts (exports)
24
+ * - /packages/lab/src/EmojiPicker/EmojiPicker.doc.mjs
25
+ * - /packages/lab/blocks/EmojiPickerShowcase.tsx
26
+ * - /packages/core/locales/en.json (@astryx.emojiPicker.*)
27
+ */
28
+ import { useEffect, useId, useMemo, useRef, useState } from 'react';
29
+ import * as stylex from '@stylexjs/stylex';
30
+ import '@astryxdesign/core/theme/tokens.stylex';
31
+ import { colorVars, durationVars, easeVars, radiusVars, spacingVars, typeScaleVars } from '@astryxdesign/core/theme/tokens.stylex';
32
+ import { useGridFocus } from '@astryxdesign/core/hooks';
33
+ import { useTranslator } from '@astryxdesign/core/i18n';
34
+ import { focusOutlineStyles, isImeKeyEvent, mergeProps, themeProps } from '@astryxdesign/core/utils';
35
+ import { Center } from '@astryxdesign/core/Center';
36
+ import { Icon } from '@astryxdesign/core/Icon';
37
+ import { HStack, VStack } from '@astryxdesign/core/Layout';
38
+ import { ScrollableArea } from '@astryxdesign/core/ScrollableArea';
39
+ import { Spinner } from '@astryxdesign/core/Spinner';
40
+ import { Text } from '@astryxdesign/core/Text';
41
+ import { TextInput } from '@astryxdesign/core/TextInput';
42
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
43
+ /** The emoji listed first, before the groups, when the host passes none. */
44
+ export const DEFAULT_FREQUENT_EMOJIS = Object.freeze([{
45
+ emoji: '👍',
46
+ name: 'Thumbs up'
47
+ }, {
48
+ emoji: '❤️',
49
+ name: 'Red heart'
50
+ }, {
51
+ emoji: '😂',
52
+ name: 'Face with tears of joy'
53
+ }, {
54
+ emoji: '🎉',
55
+ name: 'Party popper'
56
+ }, {
57
+ emoji: '😮',
58
+ name: 'Face with open mouth'
59
+ }, {
60
+ emoji: '🙏',
61
+ name: 'Folded hands'
62
+ }, {
63
+ emoji: '👀',
64
+ name: 'Eyes'
65
+ }, {
66
+ emoji: '✅',
67
+ name: 'Check mark button'
68
+ }]);
69
+ /** Eight emoji a row, so arrow keys move through a fixed geometry. */
70
+ const COLUMNS = 8;
71
+ /* The catalog keys of the bundled groups' headings, by group. */
72
+ const bundledLabelKeys = new WeakMap();
73
+ const BUNDLED_GROUP_KEYS = {
74
+ 'Smileys & emotion': '@astryx.emojiPicker.groupSmileysEmotion',
75
+ 'People & body': '@astryx.emojiPicker.groupPeopleBody',
76
+ 'Animals & nature': '@astryx.emojiPicker.groupAnimalsNature',
77
+ 'Food & drink': '@astryx.emojiPicker.groupFoodDrink',
78
+ 'Travel & places': '@astryx.emojiPicker.groupTravelPlaces',
79
+ Activities: '@astryx.emojiPicker.groupActivities',
80
+ Objects: '@astryx.emojiPicker.groupObjects',
81
+ Symbols: '@astryx.emojiPicker.groupSymbols',
82
+ Flags: '@astryx.emojiPicker.groupFlags'
83
+ };
84
+
85
+ /* The bundled set, shared by every picker once one has loaded it. */
86
+ let bundledCategories = null;
87
+ function loadBundledCategories() {
88
+ bundledCategories ??= import("./emojiData.js").then(({
89
+ EMOJI_GROUPS
90
+ }) => EMOJI_GROUPS.map(group => {
91
+ const category = {
92
+ name: group.name,
93
+ emojis: group.emojis.map(([emoji, name, keywords]) => ({
94
+ emoji,
95
+ name,
96
+ keywords: keywords === '' ? undefined : keywords.split(' ')
97
+ }))
98
+ };
99
+ const labelKey = BUNDLED_GROUP_KEYS[group.name];
100
+ if (labelKey != null) {
101
+ bundledLabelKeys.set(category, labelKey);
102
+ }
103
+ return category;
104
+ }), error => {
105
+ // Let the next picker try again.
106
+ bundledCategories = null;
107
+ throw error;
108
+ });
109
+ return bundledCategories;
110
+ }
111
+ function useCategories(categories) {
112
+ const [loaded, setLoaded] = useState('loading');
113
+ // Loading is I/O: the module arrives after the first render.
114
+ useEffect(() => {
115
+ if (categories != null) {
116
+ return;
117
+ }
118
+ let isCurrent = true;
119
+ loadBundledCategories().then(result => {
120
+ if (isCurrent) {
121
+ setLoaded(result);
122
+ }
123
+ }, () => {
124
+ if (isCurrent) {
125
+ setLoaded('failed');
126
+ }
127
+ });
128
+ return () => {
129
+ isCurrent = false;
130
+ };
131
+ }, [categories]);
132
+ return categories ?? loaded;
133
+ }
134
+
135
+ /**
136
+ * Whether an emoji's name or keywords hold every word of a search, each as
137
+ * the start of a word: `cat` finds Cat face, not Vacation.
138
+ */
139
+ function emojiMatches(option, queryWords) {
140
+ const words = [option.name, ...(option.keywords ?? [])].join(' ').toLowerCase().split(/[\s:,()-]+/);
141
+ return queryWords.every(query => words.some(word => word.startsWith(query)));
142
+ }
143
+ /** A section's emoji in rows of eight; the last row is padded with empty
144
+ * cells so every row has the grid's full geometry. */
145
+ function toRows(emojis) {
146
+ const rows = [];
147
+ for (let start = 0; start < emojis.length; start += COLUMNS) {
148
+ const row = emojis.slice(start, start + COLUMNS);
149
+ while (row.length < COLUMNS) {
150
+ row.push(null);
151
+ }
152
+ rows.push(row);
153
+ }
154
+ return rows;
155
+ }
156
+
157
+ /* A group's heading stays at the top of the list while its emoji scroll
158
+ under it, on the popover surface the picker usually sits on. */
159
+ const styles = {
160
+ categoryLabel: {
161
+ kVAEAm: "x7wzq59",
162
+ k87sOh: "x13vifvy",
163
+ kY2c9j: "x1vjfegm",
164
+ kWkggS: "x1prclbq",
165
+ $$css: true
166
+ },
167
+ cell: {
168
+ k1xSpc: "x3nfvp2",
169
+ kGNEyG: "x6s0dn4",
170
+ kjj79g: "xl56j7k",
171
+ kzqmXN: "xh8yej3",
172
+ kOBAk4: "x1plog1",
173
+ kmVPX3: "x1717udv",
174
+ ksu8eU: "xng3xce",
175
+ kaIpWk: "xx3sua9",
176
+ kWkggS: "xjbqb8w x1iutq3a xoevpu5",
177
+ kkrTdU: "x1ypdohk x16khyan",
178
+ kGuDYH: "x18juvz8",
179
+ kLWn49: "xo5v014",
180
+ kInvED: "x1wfwxd8 x1bqaal",
181
+ k1ekBW: "x15406qy",
182
+ kIyJzY: "xuedmi6 x12w9bfk",
183
+ kAMwcw: "xlr8y92",
184
+ $$css: true
185
+ },
186
+ preview: {
187
+ kAzted: "xkoleio",
188
+ $$css: true
189
+ }
190
+ };
191
+ const DEFAULT_LIST_HEIGHT = 'calc(var(--spacing-8) * 10)';
192
+
193
+ /**
194
+ * A standalone emoji panel: a search field over every emoji by group, with
195
+ * the host's frequent emoji first. Picking one calls `onSelect`; the picker
196
+ * keeps no selection of its own.
197
+ *
198
+ * @example
199
+ * ```
200
+ * <Card padding={0} width={352}>
201
+ * <EmojiPicker onSelect={emoji => insert(emoji)} />
202
+ * </Card>
203
+ * ```
204
+ */
205
+ export function EmojiPicker({
206
+ onSelect,
207
+ frequentEmojis = DEFAULT_FREQUENT_EMOJIS,
208
+ categories: categoriesProp,
209
+ height = DEFAULT_LIST_HEIGHT,
210
+ hasAutoFocus = false,
211
+ label: labelProp,
212
+ searchLabel: searchLabelProp,
213
+ xstyle,
214
+ className,
215
+ style,
216
+ ref,
217
+ ...rest
218
+ }) {
219
+ const t = useTranslator();
220
+ const label = labelProp ?? t('@astryx.emojiPicker.label');
221
+ const searchLabel = searchLabelProp ?? t('@astryx.emojiPicker.searchLabel');
222
+ const [query, setQuery] = useState('');
223
+ const [preview, setPreview] = useState(null);
224
+ const categories = useCategories(categoriesProp);
225
+ const searchRef = useRef(null);
226
+ const baseId = useId();
227
+ const {
228
+ gridRef,
229
+ handleKeyDown,
230
+ handleFocus,
231
+ focusFirst
232
+ } = useGridFocus({
233
+ columns: COLUMNS,
234
+ cellSelector: '[role="gridcell"]',
235
+ isCellFocusable: cell => cell.querySelector('button') !== null,
236
+ getFocusTarget: cell => cell.querySelector('button'),
237
+ hasRovingTabIndex: true,
238
+ // Up from the first row returns to the search field.
239
+ onNavigateBefore: (_column, offset) => {
240
+ if (offset === COLUMNS) {
241
+ searchRef.current?.focus();
242
+ }
243
+ }
244
+ });
245
+ const queryWords = useMemo(() => query.toLowerCase().split(/\s+/).filter(Boolean), [query]);
246
+ const isLoading = categories === 'loading';
247
+ const groups = useMemo(() => Array.isArray(categories) ? categories : [], [categories]);
248
+ const frequentLabel = t('@astryx.emojiPicker.frequentlyUsed');
249
+ const searchResultsLabel = t('@astryx.emojiPicker.searchResults');
250
+ const sections = useMemo(() => {
251
+ if (queryWords.length === 0) {
252
+ return [...(frequentEmojis.length > 0 ? [{
253
+ key: 'frequent',
254
+ label: frequentLabel,
255
+ emojis: frequentEmojis
256
+ }] : []), ...groups.map((group, index) => {
257
+ const labelKey = bundledLabelKeys.get(group);
258
+ return {
259
+ key: `group-${index}`,
260
+ label: labelKey != null ? t(labelKey) : group.name,
261
+ emojis: group.emojis
262
+ };
263
+ })];
264
+ }
265
+ const seen = new Set();
266
+ // Match before de-duplicating: a frequent emoji may carry no keywords, so
267
+ // its group's entry is often the one that matches.
268
+ const matches = [frequentEmojis, ...groups.map(group => group.emojis)].flat().filter(option => emojiMatches(option, queryWords)).filter(option => {
269
+ if (seen.has(option.emoji)) {
270
+ return false;
271
+ }
272
+ seen.add(option.emoji);
273
+ return true;
274
+ });
275
+ return matches.length > 0 ? [{
276
+ key: 'results',
277
+ label: searchResultsLabel,
278
+ emojis: matches
279
+ }] : [];
280
+ }, [frequentEmojis, frequentLabel, groups, queryWords, searchResultsLabel, t]);
281
+ const firstMatch = sections[0]?.emojis[0];
282
+ const noResultsMessage = !isLoading && queryWords.length > 0 && sections.length === 0 ? t('@astryx.emojiPicker.noResults', {
283
+ query: query.trim()
284
+ }) : undefined;
285
+ const pick = option => onSelect(option.emoji, option);
286
+ const handleSearchKeyDown = event => {
287
+ // While an IME is composing, the arrows move through its candidates.
288
+ if (isImeKeyEvent(event.nativeEvent)) {
289
+ return;
290
+ }
291
+ if (event.key === 'ArrowDown' && firstMatch != null) {
292
+ event.preventDefault();
293
+ focusFirst();
294
+ }
295
+ };
296
+ const renderSection = (section, sectionIndex) => {
297
+ // An id holds no spaces: aria-labelledby splits on them.
298
+ const labelId = `${baseId}-group-${sectionIndex}`;
299
+ return /*#__PURE__*/_jsxs(VStack, {
300
+ gap: 1,
301
+ width: "100%",
302
+ children: [/*#__PURE__*/_jsx(HStack, {
303
+ paddingInline: 3,
304
+ paddingBlock: 1,
305
+ xstyle: styles.categoryLabel,
306
+ children: /*#__PURE__*/_jsx(Text, {
307
+ type: "label",
308
+ id: labelId,
309
+ children: section.label
310
+ })
311
+ }), /*#__PURE__*/_jsx(VStack, {
312
+ role: "grid",
313
+ "aria-labelledby": labelId,
314
+ gap: 0.5,
315
+ width: "100%",
316
+ paddingInline: 3,
317
+ children: toRows(section.emojis).map((row, rowIndex) => /*#__PURE__*/_jsx("div", {
318
+ role: "row",
319
+ ...{
320
+ className: "xrvj5dj x1grqvj x1kihgfc"
321
+ },
322
+ children: row.map((option, column) => /*#__PURE__*/_jsx("div", {
323
+ role: "gridcell",
324
+ ...{
325
+ className: "x78zum5 xeuugli"
326
+ },
327
+ children: option != null && /*#__PURE__*/_jsx("button", {
328
+ type: "button",
329
+ "aria-label": option.name,
330
+ ...stylex.props(focusOutlineStyles.focusVisible, styles.cell),
331
+ onClick: () => pick(option),
332
+ onFocus: () => setPreview(option),
333
+ onMouseEnter: () => setPreview(option),
334
+ children: /*#__PURE__*/_jsx("span", {
335
+ "aria-hidden": "true",
336
+ children: option.emoji
337
+ })
338
+ })
339
+ }, option?.emoji ?? `empty-${column}`))
340
+ }, rowIndex))
341
+ })]
342
+ }, section.key);
343
+ };
344
+ return /*#__PURE__*/_jsxs(VStack, {
345
+ ref: ref,
346
+ gap: 2,
347
+ width: "100%"
348
+ // The picker owns its insets, so it sits edge to edge in a Popover or
349
+ // Card with no padding, its group headings spanning the full width.
350
+ ,
351
+ paddingBlockStart: 3,
352
+ ...rest,
353
+ ...mergeProps(themeProps('emoji-picker'), stylex.props(xstyle), className, style),
354
+ children: [/*#__PURE__*/_jsx(VStack, {
355
+ paddingInline: 3,
356
+ width: "100%",
357
+ children: /*#__PURE__*/_jsx(TextInput, {
358
+ ref: searchRef,
359
+ label: searchLabel,
360
+ isLabelHidden: true,
361
+ placeholder: searchLabel,
362
+ startIcon: /*#__PURE__*/_jsx(Icon, {
363
+ icon: "search",
364
+ size: "sm",
365
+ color: "secondary"
366
+ }),
367
+ hasClear: true,
368
+ hasAutoFocus: hasAutoFocus,
369
+ value: query,
370
+ onChange: value => {
371
+ setQuery(value);
372
+ setPreview(null);
373
+ },
374
+ onEnter: () => {
375
+ if (queryWords.length > 0 && firstMatch != null) {
376
+ pick(firstMatch);
377
+ }
378
+ },
379
+ onKeyDown: handleSearchKeyDown
380
+ })
381
+ }), /*#__PURE__*/_jsx(ScrollableArea, {
382
+ label: label,
383
+ height: height,
384
+ overscroll: "contain",
385
+ children: /*#__PURE__*/_jsxs(VStack, {
386
+ ref: gridRef,
387
+ gap: 3,
388
+ width: "100%",
389
+ onKeyDown: handleKeyDown,
390
+ onFocus: handleFocus,
391
+ onMouseLeave: () => setPreview(null),
392
+ children: [sections.map(renderSection), queryWords.length > 0 && sections.length === 0 && isLoading ? /*#__PURE__*/_jsx(Center, {
393
+ children: /*#__PURE__*/_jsx(Spinner, {
394
+ size: "sm",
395
+ label: t('@astryx.emojiPicker.loading')
396
+ })
397
+ }) : queryWords.length === 0 && isLoading ? /*#__PURE__*/_jsx(Center, {
398
+ children: /*#__PURE__*/_jsx(Spinner, {
399
+ size: "sm",
400
+ label: t('@astryx.emojiPicker.loading')
401
+ })
402
+ }) : queryWords.length === 0 && categories === 'failed' ? /*#__PURE__*/_jsx(HStack, {
403
+ paddingInline: 3,
404
+ children: /*#__PURE__*/_jsx(Text, {
405
+ type: "supporting",
406
+ color: "secondary",
407
+ children: t('@astryx.emojiPicker.loadFailed')
408
+ })
409
+ }) : null, /*#__PURE__*/_jsx(HStack, {
410
+ role: "status",
411
+ "aria-atomic": "true",
412
+ paddingInline: 3,
413
+ "data-emoji-picker-search-status": "true",
414
+ children: noResultsMessage != null && /*#__PURE__*/_jsx(Text, {
415
+ type: "supporting",
416
+ color: "secondary",
417
+ children: noResultsMessage
418
+ })
419
+ })]
420
+ })
421
+ }), /*#__PURE__*/_jsx(HStack, {
422
+ gap: 2,
423
+ align: "center",
424
+ paddingInline: 3,
425
+ paddingBlockEnd: 2,
426
+ xstyle: styles.preview
427
+ // The cell's own name already reaches assistive tech.
428
+ ,
429
+ "aria-hidden": "true",
430
+ children: preview != null ? /*#__PURE__*/_jsxs(_Fragment, {
431
+ children: [/*#__PURE__*/_jsx("span", {
432
+ ...{
433
+ className: "x18juvz8 xo5v014"
434
+ },
435
+ children: preview.emoji
436
+ }), /*#__PURE__*/_jsx(Text, {
437
+ type: "supporting",
438
+ color: "secondary",
439
+ maxLines: 1,
440
+ children: preview.name
441
+ })]
442
+ }) : /*#__PURE__*/_jsx(Text, {
443
+ type: "supporting",
444
+ color: "secondary",
445
+ maxLines: 1,
446
+ children: label
447
+ })
448
+ })]
449
+ });
450
+ }
451
+ EmojiPicker.displayName = 'EmojiPicker';
@@ -0,0 +1,13 @@
1
+ /** One emoji: the character, its CLDR name, and its other CLDR keywords. */
2
+ export type EmojiDatum = readonly [
3
+ emoji: string,
4
+ name: string,
5
+ keywords: string
6
+ ];
7
+ /** A CLDR emoji group, in CLDR order. */
8
+ export interface EmojiDatumGroup {
9
+ readonly name: string;
10
+ readonly emojis: readonly EmojiDatum[];
11
+ }
12
+ export declare const EMOJI_GROUPS: readonly EmojiDatumGroup[];
13
+ //# sourceMappingURL=emojiData.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"emojiData.d.ts","sourceRoot":"","sources":["../../src/EmojiPicker/emojiData.ts"],"names":[],"mappings":"AAIA,4EAA4E;AAC5E,MAAM,MAAM,UAAU,GAAG,SAAS;IAChC,KAAK,EAAE,MAAM;IACb,IAAI,EAAE,MAAM;IACZ,QAAQ,EAAE,MAAM;CACjB,CAAC;AAEF,yCAAyC;AACzC,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE,SAAS,UAAU,EAAE,CAAC;CACxC;AAED,eAAO,MAAM,YAAY,EAAE,SAAS,eAAe,EAgjGlD,CAAC"}