@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.
- package/blocks/EmojiPickerInPopover.doc.mjs +14 -0
- package/blocks/EmojiPickerInPopover.tsx +48 -0
- package/blocks/EmojiPickerShowcase.doc.mjs +15 -0
- package/blocks/EmojiPickerShowcase.tsx +33 -0
- package/dist/EmojiPicker/EmojiPicker.d.ts +96 -0
- package/dist/EmojiPicker/EmojiPicker.d.ts.map +1 -0
- package/dist/EmojiPicker/EmojiPicker.js +451 -0
- package/dist/EmojiPicker/emojiData.d.ts +13 -0
- package/dist/EmojiPicker/emojiData.d.ts.map +1 -0
- package/dist/EmojiPicker/emojiData.js +36 -0
- package/dist/EmojiPicker/index.d.ts +9 -0
- package/dist/EmojiPicker/index.d.ts.map +1 -0
- package/dist/EmojiPicker/index.js +11 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/lab.css +7 -0
- package/package.json +8 -6
- package/src/EmojiPicker/EmojiPicker.doc.mjs +186 -0
- package/src/EmojiPicker/EmojiPicker.test.tsx +276 -0
- package/src/EmojiPicker/EmojiPicker.tsx +574 -0
- package/src/EmojiPicker/emojiData.test.ts +32 -0
- package/src/EmojiPicker/emojiData.ts +3138 -0
- package/src/EmojiPicker/index.ts +13 -0
- package/src/index.ts +9 -0
|
@@ -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"}
|