@elik56a/react-native-pickers 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +276 -2
- package/lib/date/Calendar.d.ts +89 -0
- package/lib/date/Calendar.d.ts.map +1 -0
- package/lib/date/Calendar.js +249 -0
- package/lib/date/Calendar.js.map +1 -0
- package/lib/date/DatePicker.d.ts +14 -0
- package/lib/date/DatePicker.d.ts.map +1 -0
- package/lib/date/DatePicker.js +17 -0
- package/lib/date/DatePicker.js.map +1 -0
- package/lib/date/DateWheelPicker.d.ts +29 -0
- package/lib/date/DateWheelPicker.d.ts.map +1 -0
- package/lib/date/DateWheelPicker.js +83 -0
- package/lib/date/DateWheelPicker.js.map +1 -0
- package/lib/emoji/EmojiPicker.d.ts +47 -0
- package/lib/emoji/EmojiPicker.d.ts.map +1 -0
- package/lib/emoji/EmojiPicker.js +161 -0
- package/lib/emoji/EmojiPicker.js.map +1 -0
- package/lib/emoji/data.d.ts +2 -0
- package/lib/emoji/data.d.ts.map +1 -0
- package/lib/emoji/data.js +4 -0
- package/lib/emoji/data.js.map +1 -0
- package/lib/emoji/emojis.d.ts +23 -0
- package/lib/emoji/emojis.d.ts.map +1 -0
- package/lib/emoji/emojis.js +100 -0
- package/lib/emoji/emojis.js.map +1 -0
- package/lib/index.d.ts +17 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +17 -0
- package/lib/index.js.map +1 -0
- package/lib/select/SelectList.d.ts +58 -0
- package/lib/select/SelectList.d.ts.map +1 -0
- package/lib/select/SelectList.js +91 -0
- package/lib/select/SelectList.js.map +1 -0
- package/lib/select/SelectPicker.d.ts +35 -0
- package/lib/select/SelectPicker.d.ts.map +1 -0
- package/lib/select/SelectPicker.js +64 -0
- package/lib/select/SelectPicker.js.map +1 -0
- package/lib/select/filter.d.ts +3 -0
- package/lib/select/filter.d.ts.map +1 -0
- package/lib/select/filter.js +13 -0
- package/lib/select/filter.js.map +1 -0
- package/lib/sheet/BottomSheet.d.ts +29 -0
- package/lib/sheet/BottomSheet.d.ts.map +1 -0
- package/lib/sheet/BottomSheet.js +89 -0
- package/lib/sheet/BottomSheet.js.map +1 -0
- package/lib/sheet/PickerSheet.d.ts +26 -0
- package/lib/sheet/PickerSheet.d.ts.map +1 -0
- package/lib/sheet/PickerSheet.js +27 -0
- package/lib/sheet/PickerSheet.js.map +1 -0
- package/lib/sheet/sheets.d.ts +48 -0
- package/lib/sheet/sheets.d.ts.map +1 -0
- package/lib/sheet/sheets.js +58 -0
- package/lib/sheet/sheets.js.map +1 -0
- package/lib/theme.d.ts +81 -0
- package/lib/theme.d.ts.map +1 -0
- package/lib/theme.js +96 -0
- package/lib/theme.js.map +1 -0
- package/lib/time/TimePicker.d.ts +41 -0
- package/lib/time/TimePicker.d.ts.map +1 -0
- package/lib/time/TimePicker.js +105 -0
- package/lib/time/TimePicker.js.map +1 -0
- package/lib/types.d.ts +9 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +2 -0
- package/lib/types.js.map +1 -0
- package/lib/utils/OwnScroll.d.ts +10 -0
- package/lib/utils/OwnScroll.d.ts.map +1 -0
- package/lib/utils/OwnScroll.js +13 -0
- package/lib/utils/OwnScroll.js.map +1 -0
- package/lib/utils/date.d.ts +30 -0
- package/lib/utils/date.d.ts.map +1 -0
- package/lib/utils/date.js +115 -0
- package/lib/utils/date.js.map +1 -0
- package/lib/wheel/MultiWheelPicker.d.ts +26 -0
- package/lib/wheel/MultiWheelPicker.d.ts.map +1 -0
- package/lib/wheel/MultiWheelPicker.js +28 -0
- package/lib/wheel/MultiWheelPicker.js.map +1 -0
- package/lib/wheel/WheelPicker.d.ts +59 -0
- package/lib/wheel/WheelPicker.d.ts.map +1 -0
- package/lib/wheel/WheelPicker.js +257 -0
- package/lib/wheel/WheelPicker.js.map +1 -0
- package/lib/wheel/typeToSelect.d.ts +8 -0
- package/lib/wheel/typeToSelect.d.ts.map +1 -0
- package/lib/wheel/typeToSelect.js +10 -0
- package/lib/wheel/typeToSelect.js.map +1 -0
- package/package.json +68 -3
- package/src/date/Calendar.tsx +586 -0
- package/src/date/DatePicker.tsx +22 -0
- package/src/date/DateWheelPicker.tsx +149 -0
- package/src/emoji/EmojiPicker.tsx +410 -0
- package/src/emoji/data.ts +3 -0
- package/src/emoji/emojis.ts +127 -0
- package/src/index.ts +16 -0
- package/src/select/SelectList.tsx +263 -0
- package/src/select/SelectPicker.tsx +203 -0
- package/src/select/filter.ts +17 -0
- package/src/sheet/BottomSheet.tsx +199 -0
- package/src/sheet/PickerSheet.tsx +86 -0
- package/src/sheet/sheets.tsx +218 -0
- package/src/theme.tsx +160 -0
- package/src/time/TimePicker.tsx +199 -0
- package/src/types.ts +10 -0
- package/src/utils/OwnScroll.tsx +14 -0
- package/src/utils/date.ts +131 -0
- package/src/wheel/MultiWheelPicker.tsx +96 -0
- package/src/wheel/WheelPicker.tsx +534 -0
- package/src/wheel/typeToSelect.ts +12 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from 'react';
|
|
2
|
+
import type { StyleProp, ViewStyle } from 'react-native';
|
|
3
|
+
import { MultiWheelPicker } from '../wheel/MultiWheelPicker';
|
|
4
|
+
import type { MultiWheelSlot, WheelColumn } from '../wheel/MultiWheelPicker';
|
|
5
|
+
import { clampDate, daysInMonth, getMonthNames, isOutOfRange } from '../utils/date';
|
|
6
|
+
import type { SlotStyles, ThemableProps } from '../types';
|
|
7
|
+
|
|
8
|
+
export type DatePart = 'day' | 'month' | 'year';
|
|
9
|
+
|
|
10
|
+
export interface DateWheelPickerProps extends ThemableProps {
|
|
11
|
+
value?: Date;
|
|
12
|
+
defaultValue?: Date;
|
|
13
|
+
onChange?: (date: Date) => void;
|
|
14
|
+
onValueChanging?: (date: Date) => void;
|
|
15
|
+
locale?: string;
|
|
16
|
+
minDate?: Date;
|
|
17
|
+
maxDate?: Date;
|
|
18
|
+
/** Column order. Default: from locale (e.g. M/D/Y for en-US, D/M/Y for he) */
|
|
19
|
+
order?: DatePart[];
|
|
20
|
+
/** 'long' (January), 'short' (Jan) or 'numeric' (01). Default: 'long' */
|
|
21
|
+
monthFormat?: 'long' | 'short' | 'numeric';
|
|
22
|
+
/** Years when no min/max is given. Default: ±100 from today */
|
|
23
|
+
yearRange?: [number, number];
|
|
24
|
+
/** Loop the day and month wheels. Default: true */
|
|
25
|
+
loop?: boolean;
|
|
26
|
+
itemHeight?: number;
|
|
27
|
+
visibleItems?: number;
|
|
28
|
+
variant?: '3d' | 'flat';
|
|
29
|
+
style?: StyleProp<ViewStyle>;
|
|
30
|
+
styles?: SlotStyles<MultiWheelSlot>;
|
|
31
|
+
testID?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function localeOrder(locale?: string): DatePart[] {
|
|
35
|
+
try {
|
|
36
|
+
const parts = new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'long', year: 'numeric' }).formatToParts(new Date(2021, 10, 23));
|
|
37
|
+
const order = parts.map((p) => p.type).filter((t): t is DatePart => t === 'day' || t === 'month' || t === 'year');
|
|
38
|
+
if (order.length === 3) return order;
|
|
39
|
+
} catch {
|
|
40
|
+
// fall through
|
|
41
|
+
}
|
|
42
|
+
return ['month', 'day', 'year'];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function DateWheelPicker({
|
|
46
|
+
value,
|
|
47
|
+
defaultValue,
|
|
48
|
+
onChange,
|
|
49
|
+
onValueChanging,
|
|
50
|
+
locale,
|
|
51
|
+
minDate,
|
|
52
|
+
maxDate,
|
|
53
|
+
order,
|
|
54
|
+
monthFormat = 'long',
|
|
55
|
+
yearRange,
|
|
56
|
+
loop = true,
|
|
57
|
+
itemHeight,
|
|
58
|
+
visibleItems,
|
|
59
|
+
variant,
|
|
60
|
+
style,
|
|
61
|
+
styles,
|
|
62
|
+
theme,
|
|
63
|
+
testID,
|
|
64
|
+
}: DateWheelPickerProps) {
|
|
65
|
+
const [inner, setInner] = useState<Date>(() => clampDate(value ?? defaultValue ?? new Date(), minDate, maxDate));
|
|
66
|
+
const date = value ?? inner;
|
|
67
|
+
const y = date.getFullYear();
|
|
68
|
+
const m = date.getMonth();
|
|
69
|
+
const d = date.getDate();
|
|
70
|
+
const cols = order ?? localeOrder(locale);
|
|
71
|
+
const now = new Date();
|
|
72
|
+
const minY = minDate?.getFullYear() ?? yearRange?.[0] ?? now.getFullYear() - 100;
|
|
73
|
+
const maxY = maxDate?.getFullYear() ?? yearRange?.[1] ?? now.getFullYear() + 100;
|
|
74
|
+
|
|
75
|
+
const make = useCallback(
|
|
76
|
+
(patch: { y?: number; m?: number; d?: number }) => {
|
|
77
|
+
const ny = patch.y ?? y;
|
|
78
|
+
const nm = patch.m ?? m;
|
|
79
|
+
const nd = Math.min(patch.d ?? d, daysInMonth(ny, nm));
|
|
80
|
+
const out = new Date(date);
|
|
81
|
+
out.setFullYear(ny, nm, nd);
|
|
82
|
+
return clampDate(out, minDate, maxDate);
|
|
83
|
+
},
|
|
84
|
+
[d, date, m, maxDate, minDate, y]
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const commit = useCallback(
|
|
88
|
+
(next: Date) => {
|
|
89
|
+
if (value === undefined) setInner(next);
|
|
90
|
+
onChange?.(next);
|
|
91
|
+
},
|
|
92
|
+
[onChange, value]
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const columns = useMemo(() => {
|
|
96
|
+
const monthNames = monthFormat === 'numeric' ? null : getMonthNames(locale, monthFormat);
|
|
97
|
+
const handlers = (pick: (v: number) => Date) => ({
|
|
98
|
+
onChange: (v: number) => commit(pick(v)),
|
|
99
|
+
onValueChanging: onValueChanging ? (v: number) => onValueChanging(pick(v)) : undefined,
|
|
100
|
+
});
|
|
101
|
+
const all: Record<DatePart, WheelColumn<number>> = {
|
|
102
|
+
day: {
|
|
103
|
+
key: 'day',
|
|
104
|
+
items: Array.from({ length: daysInMonth(y, m) }, (_, i) => ({
|
|
105
|
+
value: i + 1,
|
|
106
|
+
label: String(i + 1).padStart(monthFormat === 'numeric' ? 2 : 1, '0'),
|
|
107
|
+
disabled: isOutOfRange(new Date(y, m, i + 1), minDate, maxDate),
|
|
108
|
+
})),
|
|
109
|
+
value: d,
|
|
110
|
+
loop,
|
|
111
|
+
flex: 0.8,
|
|
112
|
+
...handlers((v) => make({ d: v })),
|
|
113
|
+
},
|
|
114
|
+
month: {
|
|
115
|
+
key: 'month',
|
|
116
|
+
items: Array.from({ length: 12 }, (_, i) => ({
|
|
117
|
+
value: i,
|
|
118
|
+
label: monthNames ? monthNames[i]! : String(i + 1).padStart(2, '0'),
|
|
119
|
+
disabled: isOutOfRange(new Date(y, i, daysInMonth(y, i)), minDate, null) || isOutOfRange(new Date(y, i, 1), null, maxDate),
|
|
120
|
+
})),
|
|
121
|
+
value: m,
|
|
122
|
+
loop,
|
|
123
|
+
flex: monthFormat === 'long' ? 1.6 : 1,
|
|
124
|
+
...handlers((v) => make({ m: v })),
|
|
125
|
+
},
|
|
126
|
+
year: {
|
|
127
|
+
key: 'year',
|
|
128
|
+
items: Array.from({ length: maxY - minY + 1 }, (_, i) => minY + i),
|
|
129
|
+
value: y,
|
|
130
|
+
flex: 1,
|
|
131
|
+
...handlers((v) => make({ y: v })),
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
return cols.map((c) => all[c]);
|
|
135
|
+
}, [cols, commit, d, locale, loop, m, make, maxDate, maxY, minDate, minY, monthFormat, onValueChanging, y]);
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<MultiWheelPicker
|
|
139
|
+
columns={columns}
|
|
140
|
+
itemHeight={itemHeight}
|
|
141
|
+
visibleItems={visibleItems}
|
|
142
|
+
variant={variant}
|
|
143
|
+
style={style}
|
|
144
|
+
styles={styles}
|
|
145
|
+
theme={theme}
|
|
146
|
+
testID={testID}
|
|
147
|
+
/>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { FlatList, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
|
|
3
|
+
import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle } from 'react-native';
|
|
4
|
+
import { textStyle, usePickerTheme } from '../theme';
|
|
5
|
+
import type { PickerTheme } from '../theme';
|
|
6
|
+
import type { SlotStyles, ThemableProps } from '../types';
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_CATEGORY_ICONS,
|
|
9
|
+
DEFAULT_CATEGORY_LABELS,
|
|
10
|
+
EMOJI_CATEGORIES,
|
|
11
|
+
findEmoji,
|
|
12
|
+
getAllEmojis,
|
|
13
|
+
searchEmojis,
|
|
14
|
+
withSkinTone,
|
|
15
|
+
} from './emojis';
|
|
16
|
+
import type { Emoji, EmojiCategory, SkinTone } from './emojis';
|
|
17
|
+
import { OwnScroll } from '../utils/OwnScroll';
|
|
18
|
+
|
|
19
|
+
type SectionKey = EmojiCategory | 'recent' | 'search';
|
|
20
|
+
|
|
21
|
+
export type EmojiPickerSlot =
|
|
22
|
+
| 'container'
|
|
23
|
+
| 'header'
|
|
24
|
+
| 'searchInput'
|
|
25
|
+
| 'skinToneButton'
|
|
26
|
+
| 'skinToneMenu'
|
|
27
|
+
| 'categoryBar'
|
|
28
|
+
| 'categoryTab'
|
|
29
|
+
| 'activeCategoryTab'
|
|
30
|
+
| 'categoryIndicator'
|
|
31
|
+
| 'sectionHeader'
|
|
32
|
+
| 'sectionHeaderText'
|
|
33
|
+
| 'emojiCell'
|
|
34
|
+
| 'emojiText'
|
|
35
|
+
| 'empty'
|
|
36
|
+
| 'emptyText';
|
|
37
|
+
|
|
38
|
+
export interface EmojiPickerProps extends ThemableProps {
|
|
39
|
+
onSelect: (emoji: string, data: Emoji) => void;
|
|
40
|
+
/** Emojis per row. Default: 8 */
|
|
41
|
+
columns?: number;
|
|
42
|
+
/** Font size of emojis. Default: ~60% of the cell */
|
|
43
|
+
emojiSize?: number;
|
|
44
|
+
/** Height of the picker. Default: 380 */
|
|
45
|
+
height?: number;
|
|
46
|
+
/** Which categories to show, in order */
|
|
47
|
+
categories?: EmojiCategory[];
|
|
48
|
+
/** Emoji characters to hide */
|
|
49
|
+
exclude?: string[];
|
|
50
|
+
/** Hide emojis newer than this Emoji version (older Android can't render them). */
|
|
51
|
+
maxEmojiVersion?: number;
|
|
52
|
+
searchable?: boolean;
|
|
53
|
+
searchPlaceholder?: string;
|
|
54
|
+
emptyText?: string;
|
|
55
|
+
showCategoryBar?: boolean;
|
|
56
|
+
categoryBarPosition?: 'top' | 'bottom';
|
|
57
|
+
showSectionHeaders?: boolean;
|
|
58
|
+
categoryLabels?: Partial<Record<SectionKey, string>>;
|
|
59
|
+
categoryIcons?: Partial<Record<EmojiCategory | 'recent', React.ReactNode>>;
|
|
60
|
+
/** Controlled skin tone (0 default, 1–5 light → dark) */
|
|
61
|
+
skinTone?: SkinTone;
|
|
62
|
+
defaultSkinTone?: SkinTone;
|
|
63
|
+
onSkinToneChange?: (tone: SkinTone) => void;
|
|
64
|
+
showSkinTones?: boolean;
|
|
65
|
+
/** Recently used emojis (characters). Persist with onRecentChange. */
|
|
66
|
+
recent?: string[];
|
|
67
|
+
defaultRecent?: string[];
|
|
68
|
+
onRecentChange?: (recent: string[]) => void;
|
|
69
|
+
showRecent?: boolean;
|
|
70
|
+
maxRecent?: number;
|
|
71
|
+
renderEmoji?: (emoji: string, data: Emoji, size: number) => React.ReactNode;
|
|
72
|
+
style?: StyleProp<ViewStyle>;
|
|
73
|
+
styles?: SlotStyles<EmojiPickerSlot>;
|
|
74
|
+
testID?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type Row = { type: 'header'; key: SectionKey } | { type: 'emojis'; items: Emoji[]; section: SectionKey };
|
|
78
|
+
|
|
79
|
+
const HEADER_H = 32;
|
|
80
|
+
const TONE_SWATCH = ['✋', '✋🏻', '✋🏼', '✋🏽', '✋🏾', '✋🏿'];
|
|
81
|
+
|
|
82
|
+
export function EmojiPicker({
|
|
83
|
+
onSelect,
|
|
84
|
+
columns = 8,
|
|
85
|
+
emojiSize,
|
|
86
|
+
height = 380,
|
|
87
|
+
categories = EMOJI_CATEGORIES,
|
|
88
|
+
exclude,
|
|
89
|
+
maxEmojiVersion,
|
|
90
|
+
searchable = true,
|
|
91
|
+
searchPlaceholder = 'Search emoji',
|
|
92
|
+
emptyText = 'No emoji found',
|
|
93
|
+
showCategoryBar = true,
|
|
94
|
+
categoryBarPosition = 'top',
|
|
95
|
+
showSectionHeaders = true,
|
|
96
|
+
categoryLabels,
|
|
97
|
+
categoryIcons,
|
|
98
|
+
skinTone: skinToneProp,
|
|
99
|
+
defaultSkinTone = 0,
|
|
100
|
+
onSkinToneChange,
|
|
101
|
+
showSkinTones = true,
|
|
102
|
+
recent: recentProp,
|
|
103
|
+
defaultRecent = [],
|
|
104
|
+
onRecentChange,
|
|
105
|
+
showRecent = true,
|
|
106
|
+
maxRecent = 24,
|
|
107
|
+
renderEmoji,
|
|
108
|
+
style,
|
|
109
|
+
styles: slots,
|
|
110
|
+
theme: themeOverride,
|
|
111
|
+
testID,
|
|
112
|
+
}: EmojiPickerProps) {
|
|
113
|
+
const theme = usePickerTheme(themeOverride);
|
|
114
|
+
const c = theme.colors;
|
|
115
|
+
const [width, setWidth] = useState(0);
|
|
116
|
+
const [query, setQuery] = useState('');
|
|
117
|
+
const [toneMenu, setToneMenu] = useState(false);
|
|
118
|
+
const [innerTone, setInnerTone] = useState<SkinTone>(defaultSkinTone);
|
|
119
|
+
const tone = skinToneProp ?? innerTone;
|
|
120
|
+
const [innerRecent, setInnerRecent] = useState<string[]>(defaultRecent);
|
|
121
|
+
const recent = recentProp ?? innerRecent;
|
|
122
|
+
const [active, setActive] = useState<SectionKey>(categories[0] ?? 'smileys');
|
|
123
|
+
const listRef = useRef<FlatList<Row>>(null);
|
|
124
|
+
const jumping = useRef(false);
|
|
125
|
+
|
|
126
|
+
const cell = width > 0 ? Math.floor(width / columns) : 40;
|
|
127
|
+
const fontSize = emojiSize ?? Math.round(cell * 0.62);
|
|
128
|
+
const labels = { ...DEFAULT_CATEGORY_LABELS, ...categoryLabels };
|
|
129
|
+
const icons = { ...DEFAULT_CATEGORY_ICONS, ...categoryIcons };
|
|
130
|
+
|
|
131
|
+
const pool = useMemo(() => {
|
|
132
|
+
const ex = exclude ? new Set(exclude) : null;
|
|
133
|
+
const cats = new Set(categories);
|
|
134
|
+
return getAllEmojis().filter(
|
|
135
|
+
(e) => cats.has(e.category) && (maxEmojiVersion === undefined || e.version <= maxEmojiVersion) && !(ex && ex.has(e.emoji))
|
|
136
|
+
);
|
|
137
|
+
}, [categories, exclude, maxEmojiVersion]);
|
|
138
|
+
|
|
139
|
+
const recentEmojis = useMemo(
|
|
140
|
+
() => recent.map((ch) => findEmoji(ch)).filter((e): e is Emoji => !!e && pool.includes(e)),
|
|
141
|
+
[pool, recent]
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const sections = useMemo(() => {
|
|
145
|
+
if (query.trim()) return [{ key: 'search' as SectionKey, items: searchEmojis(query, pool) }];
|
|
146
|
+
const out: { key: SectionKey; items: Emoji[] }[] = [];
|
|
147
|
+
if (showRecent && recentEmojis.length) out.push({ key: 'recent', items: recentEmojis });
|
|
148
|
+
for (const cat of categories) {
|
|
149
|
+
const items = pool.filter((e) => e.category === cat);
|
|
150
|
+
if (items.length) out.push({ key: cat, items });
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}, [categories, pool, query, recentEmojis, showRecent]);
|
|
154
|
+
|
|
155
|
+
// Flatten into rows with known heights so we can jump to categories.
|
|
156
|
+
const { rows, offsets, headerIndex } = useMemo(() => {
|
|
157
|
+
const rows: Row[] = [];
|
|
158
|
+
const offsets: number[] = [];
|
|
159
|
+
const headerIndex = new Map<SectionKey, number>();
|
|
160
|
+
let y = 0;
|
|
161
|
+
for (const sec of sections) {
|
|
162
|
+
if (showSectionHeaders || sec.key !== 'search') {
|
|
163
|
+
headerIndex.set(sec.key, rows.length);
|
|
164
|
+
if (showSectionHeaders) {
|
|
165
|
+
rows.push({ type: 'header', key: sec.key });
|
|
166
|
+
offsets.push(y);
|
|
167
|
+
y += HEADER_H;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
for (let i = 0; i < sec.items.length; i += columns) {
|
|
171
|
+
if (!showSectionHeaders && i === 0) headerIndex.set(sec.key, rows.length);
|
|
172
|
+
rows.push({ type: 'emojis', items: sec.items.slice(i, i + columns), section: sec.key });
|
|
173
|
+
offsets.push(y);
|
|
174
|
+
y += cell;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { rows, offsets, headerIndex };
|
|
178
|
+
}, [cell, columns, sections, showSectionHeaders]);
|
|
179
|
+
|
|
180
|
+
const rowLength = useCallback((r: Row) => (r.type === 'header' ? HEADER_H : cell), [cell]);
|
|
181
|
+
|
|
182
|
+
const select = useCallback(
|
|
183
|
+
(e: Emoji) => {
|
|
184
|
+
const ch = withSkinTone(e, tone);
|
|
185
|
+
onSelect(ch, e);
|
|
186
|
+
if (showRecent) {
|
|
187
|
+
const next = [e.emoji, ...recent.filter((r) => findEmoji(r) !== e)].slice(0, maxRecent);
|
|
188
|
+
if (recentProp === undefined) setInnerRecent(next);
|
|
189
|
+
onRecentChange?.(next);
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
[maxRecent, onRecentChange, onSelect, recent, recentProp, showRecent, tone]
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const setTone = (t: SkinTone) => {
|
|
196
|
+
if (skinToneProp === undefined) setInnerTone(t);
|
|
197
|
+
onSkinToneChange?.(t);
|
|
198
|
+
setToneMenu(false);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const jumpTo = (key: SectionKey) => {
|
|
202
|
+
const idx = headerIndex.get(key);
|
|
203
|
+
if (idx === undefined) return;
|
|
204
|
+
setActive(key);
|
|
205
|
+
jumping.current = true;
|
|
206
|
+
listRef.current?.scrollToOffset({ offset: offsets[idx] ?? 0, animated: true });
|
|
207
|
+
setTimeout(() => (jumping.current = false), 450);
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const onScroll = useCallback(
|
|
211
|
+
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
212
|
+
if (jumping.current || query) return;
|
|
213
|
+
const y = e.nativeEvent.contentOffset.y + 4;
|
|
214
|
+
// binary search the row at y
|
|
215
|
+
let lo = 0;
|
|
216
|
+
let hi = offsets.length - 1;
|
|
217
|
+
while (lo < hi) {
|
|
218
|
+
const mid = (lo + hi + 1) >> 1;
|
|
219
|
+
if (offsets[mid]! <= y) lo = mid;
|
|
220
|
+
else hi = mid - 1;
|
|
221
|
+
}
|
|
222
|
+
const r = rows[lo];
|
|
223
|
+
const key = r ? (r.type === 'header' ? r.key : r.section) : undefined;
|
|
224
|
+
if (key && key !== active) setActive(key);
|
|
225
|
+
},
|
|
226
|
+
[active, offsets, query, rows]
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
const onLayout = (e: LayoutChangeEvent) => setWidth(e.nativeEvent.layout.width);
|
|
230
|
+
|
|
231
|
+
const renderRow = ({ item }: { item: Row }) => {
|
|
232
|
+
if (item.type === 'header') {
|
|
233
|
+
return (
|
|
234
|
+
<View style={[{ height: HEADER_H, justifyContent: 'flex-end', paddingHorizontal: 4, paddingBottom: 6 }, slots?.sectionHeader]}>
|
|
235
|
+
<Text style={[textStyle(theme, 'xs', 'bold'), { color: c.textSecondary }, slots?.sectionHeaderText]}>{labels[item.key]}</Text>
|
|
236
|
+
</View>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
return (
|
|
240
|
+
<View style={s.row}>
|
|
241
|
+
{item.items.map((e) => (
|
|
242
|
+
<EmojiCell
|
|
243
|
+
key={e.emoji}
|
|
244
|
+
e={e}
|
|
245
|
+
ch={withSkinTone(e, tone)}
|
|
246
|
+
size={cell}
|
|
247
|
+
fontSize={fontSize}
|
|
248
|
+
theme={theme}
|
|
249
|
+
slots={slots}
|
|
250
|
+
renderEmoji={renderEmoji}
|
|
251
|
+
onPress={select}
|
|
252
|
+
/>
|
|
253
|
+
))}
|
|
254
|
+
</View>
|
|
255
|
+
);
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const categoryBar = showCategoryBar && !query && (
|
|
259
|
+
<View style={[s.bar, { borderColor: c.border }, categoryBarPosition === 'top' ? { borderBottomWidth: StyleSheet.hairlineWidth } : { borderTopWidth: StyleSheet.hairlineWidth }, slots?.categoryBar]}>
|
|
260
|
+
{(['recent', ...categories] as const)
|
|
261
|
+
.filter((k) => headerIndex.has(k))
|
|
262
|
+
.map((k) => {
|
|
263
|
+
const on = active === k;
|
|
264
|
+
const icon = icons[k as EmojiCategory | 'recent'];
|
|
265
|
+
return (
|
|
266
|
+
<Pressable
|
|
267
|
+
key={k}
|
|
268
|
+
onPress={() => jumpTo(k)}
|
|
269
|
+
accessibilityRole="tab"
|
|
270
|
+
accessibilityLabel={labels[k]}
|
|
271
|
+
accessibilityState={{ selected: on }}
|
|
272
|
+
style={[s.tab, slots?.categoryTab, on && slots?.activeCategoryTab]}
|
|
273
|
+
>
|
|
274
|
+
{typeof icon === 'string' ? <Text style={{ fontSize: 18, opacity: on ? 1 : 0.45 }}>{icon}</Text> : icon}
|
|
275
|
+
<View
|
|
276
|
+
style={[
|
|
277
|
+
s.tabIndicator,
|
|
278
|
+
{ backgroundColor: on ? c.primary : 'transparent', [categoryBarPosition === 'top' ? 'bottom' : 'top']: 0 },
|
|
279
|
+
slots?.categoryIndicator,
|
|
280
|
+
]}
|
|
281
|
+
/>
|
|
282
|
+
</Pressable>
|
|
283
|
+
);
|
|
284
|
+
})}
|
|
285
|
+
</View>
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
return (
|
|
289
|
+
<View style={[{ height, backgroundColor: c.background }, style, slots?.container]} testID={testID}>
|
|
290
|
+
{(searchable || showSkinTones) && (
|
|
291
|
+
<View style={[s.header, { paddingHorizontal: theme.spacing.sm, paddingTop: theme.spacing.sm, gap: theme.spacing.sm }, slots?.header]}>
|
|
292
|
+
{searchable ? (
|
|
293
|
+
<TextInput
|
|
294
|
+
value={query}
|
|
295
|
+
onChangeText={(t) => {
|
|
296
|
+
setQuery(t);
|
|
297
|
+
listRef.current?.scrollToOffset({ offset: 0, animated: false });
|
|
298
|
+
}}
|
|
299
|
+
placeholder={searchPlaceholder}
|
|
300
|
+
placeholderTextColor={c.textSecondary}
|
|
301
|
+
autoCorrect={false}
|
|
302
|
+
autoCapitalize="none"
|
|
303
|
+
clearButtonMode="while-editing"
|
|
304
|
+
accessibilityLabel={searchPlaceholder}
|
|
305
|
+
style={[
|
|
306
|
+
textStyle(theme, 'md'),
|
|
307
|
+
s.flex,
|
|
308
|
+
{ backgroundColor: c.surface, borderRadius: theme.radius.md, paddingHorizontal: theme.spacing.md, height: 40 },
|
|
309
|
+
slots?.searchInput,
|
|
310
|
+
]}
|
|
311
|
+
/>
|
|
312
|
+
) : (
|
|
313
|
+
<View style={s.flex} />
|
|
314
|
+
)}
|
|
315
|
+
{showSkinTones && (
|
|
316
|
+
<Pressable
|
|
317
|
+
onPress={() => setToneMenu((v) => !v)}
|
|
318
|
+
accessibilityRole="button"
|
|
319
|
+
accessibilityLabel="Skin tone"
|
|
320
|
+
style={[s.toneBtn, { backgroundColor: c.surface, borderRadius: theme.radius.md }, slots?.skinToneButton]}
|
|
321
|
+
>
|
|
322
|
+
<Text style={{ fontSize: 20 }}>{TONE_SWATCH[tone]}</Text>
|
|
323
|
+
</Pressable>
|
|
324
|
+
)}
|
|
325
|
+
</View>
|
|
326
|
+
)}
|
|
327
|
+
{toneMenu && (
|
|
328
|
+
<View style={[s.toneMenu, { marginHorizontal: theme.spacing.sm, backgroundColor: c.surface, borderRadius: theme.radius.md }, slots?.skinToneMenu]}>
|
|
329
|
+
{TONE_SWATCH.map((sw, i) => (
|
|
330
|
+
<Pressable
|
|
331
|
+
key={i}
|
|
332
|
+
onPress={() => setTone(i as SkinTone)}
|
|
333
|
+
accessibilityRole="button"
|
|
334
|
+
accessibilityState={{ selected: i === tone }}
|
|
335
|
+
style={[s.toneOpt, { borderRadius: theme.radius.sm, backgroundColor: i === tone ? c.primaryMuted : 'transparent' }]}
|
|
336
|
+
>
|
|
337
|
+
<Text style={{ fontSize: 22 }}>{sw}</Text>
|
|
338
|
+
</Pressable>
|
|
339
|
+
))}
|
|
340
|
+
</View>
|
|
341
|
+
)}
|
|
342
|
+
{categoryBarPosition === 'top' && categoryBar}
|
|
343
|
+
<View style={[s.flex, { paddingHorizontal: theme.spacing.xs }]} onLayout={onLayout}>
|
|
344
|
+
{width > 0 && (
|
|
345
|
+
<OwnScroll>
|
|
346
|
+
<FlatList
|
|
347
|
+
ref={listRef}
|
|
348
|
+
data={rows}
|
|
349
|
+
renderItem={renderRow}
|
|
350
|
+
keyExtractor={(r, i) => (r.type === 'header' ? `h-${r.key}` : `r-${r.section}-${i}`)}
|
|
351
|
+
getItemLayout={(_, i) => ({ index: i, length: rows[i] ? rowLength(rows[i]!) : cell, offset: offsets[i] ?? 0 })}
|
|
352
|
+
onScroll={onScroll}
|
|
353
|
+
scrollEventThrottle={32}
|
|
354
|
+
keyboardShouldPersistTaps="handled"
|
|
355
|
+
initialNumToRender={Math.ceil(height / cell) + 2}
|
|
356
|
+
maxToRenderPerBatch={8}
|
|
357
|
+
windowSize={7}
|
|
358
|
+
ListEmptyComponent={
|
|
359
|
+
<View style={[{ padding: theme.spacing.xl, alignItems: 'center' }, slots?.empty]}>
|
|
360
|
+
<Text style={[textStyle(theme, 'md'), { color: c.textSecondary }, slots?.emptyText]}>{emptyText}</Text>
|
|
361
|
+
</View>
|
|
362
|
+
}
|
|
363
|
+
/>
|
|
364
|
+
</OwnScroll>
|
|
365
|
+
)}
|
|
366
|
+
</View>
|
|
367
|
+
{categoryBarPosition === 'bottom' && categoryBar}
|
|
368
|
+
</View>
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
interface CellProps {
|
|
373
|
+
e: Emoji;
|
|
374
|
+
ch: string;
|
|
375
|
+
size: number;
|
|
376
|
+
fontSize: number;
|
|
377
|
+
theme: PickerTheme;
|
|
378
|
+
slots?: SlotStyles<EmojiPickerSlot>;
|
|
379
|
+
renderEmoji?: (emoji: string, data: Emoji, size: number) => React.ReactNode;
|
|
380
|
+
onPress: (e: Emoji) => void;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const EmojiCell = memo(function EmojiCell({ e, ch, size, fontSize, theme, slots, renderEmoji, onPress }: CellProps) {
|
|
384
|
+
return (
|
|
385
|
+
<Pressable
|
|
386
|
+
onPress={() => onPress(e)}
|
|
387
|
+
accessibilityRole="button"
|
|
388
|
+
accessibilityLabel={e.name}
|
|
389
|
+
style={({ pressed }) => [
|
|
390
|
+
{ width: size, height: size, alignItems: 'center', justifyContent: 'center', borderRadius: theme.radius.sm },
|
|
391
|
+
pressed && { backgroundColor: theme.colors.surface, transform: [{ scale: 1.15 }] },
|
|
392
|
+
slots?.emojiCell,
|
|
393
|
+
]}
|
|
394
|
+
>
|
|
395
|
+
{renderEmoji ? renderEmoji(ch, e, fontSize) : <Text style={[{ fontSize, lineHeight: fontSize * 1.2 }, slots?.emojiText]}>{ch}</Text>}
|
|
396
|
+
</Pressable>
|
|
397
|
+
);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
const s = StyleSheet.create({
|
|
401
|
+
flex: { flex: 1 },
|
|
402
|
+
row: { flexDirection: 'row' },
|
|
403
|
+
header: { flexDirection: 'row', alignItems: 'center' },
|
|
404
|
+
toneBtn: { width: 40, height: 40, alignItems: 'center', justifyContent: 'center' },
|
|
405
|
+
toneMenu: { flexDirection: 'row', justifyContent: 'space-around', padding: 4, marginTop: 6 },
|
|
406
|
+
toneOpt: { padding: 6 },
|
|
407
|
+
bar: { flexDirection: 'row', marginTop: 6 },
|
|
408
|
+
tab: { flex: 1, height: 40, alignItems: 'center', justifyContent: 'center' },
|
|
409
|
+
tabIndicator: { position: 'absolute', left: '25%', right: '25%', height: 2.5, borderRadius: 2 },
|
|
410
|
+
});
|