@elik56a/react-native-pickers 0.0.0-stage → 0.1.1

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.
Files changed (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +288 -2
  3. package/lib/date/Calendar.d.ts +89 -0
  4. package/lib/date/Calendar.d.ts.map +1 -0
  5. package/lib/date/Calendar.js +250 -0
  6. package/lib/date/Calendar.js.map +1 -0
  7. package/lib/date/DatePicker.d.ts +14 -0
  8. package/lib/date/DatePicker.d.ts.map +1 -0
  9. package/lib/date/DatePicker.js +17 -0
  10. package/lib/date/DatePicker.js.map +1 -0
  11. package/lib/date/DateWheelPicker.d.ts +29 -0
  12. package/lib/date/DateWheelPicker.d.ts.map +1 -0
  13. package/lib/date/DateWheelPicker.js +83 -0
  14. package/lib/date/DateWheelPicker.js.map +1 -0
  15. package/lib/emoji/EmojiPicker.d.ts +47 -0
  16. package/lib/emoji/EmojiPicker.d.ts.map +1 -0
  17. package/lib/emoji/EmojiPicker.js +161 -0
  18. package/lib/emoji/EmojiPicker.js.map +1 -0
  19. package/lib/emoji/data.d.ts +2 -0
  20. package/lib/emoji/data.d.ts.map +1 -0
  21. package/lib/emoji/data.js +4 -0
  22. package/lib/emoji/data.js.map +1 -0
  23. package/lib/emoji/emojis.d.ts +23 -0
  24. package/lib/emoji/emojis.d.ts.map +1 -0
  25. package/lib/emoji/emojis.js +100 -0
  26. package/lib/emoji/emojis.js.map +1 -0
  27. package/lib/index.d.ts +17 -0
  28. package/lib/index.d.ts.map +1 -0
  29. package/lib/index.js +17 -0
  30. package/lib/index.js.map +1 -0
  31. package/lib/select/SelectList.d.ts +60 -0
  32. package/lib/select/SelectList.d.ts.map +1 -0
  33. package/lib/select/SelectList.js +91 -0
  34. package/lib/select/SelectList.js.map +1 -0
  35. package/lib/select/SelectPicker.d.ts +35 -0
  36. package/lib/select/SelectPicker.d.ts.map +1 -0
  37. package/lib/select/SelectPicker.js +64 -0
  38. package/lib/select/SelectPicker.js.map +1 -0
  39. package/lib/select/filter.d.ts +3 -0
  40. package/lib/select/filter.d.ts.map +1 -0
  41. package/lib/select/filter.js +13 -0
  42. package/lib/select/filter.js.map +1 -0
  43. package/lib/sheet/BottomSheet.d.ts +29 -0
  44. package/lib/sheet/BottomSheet.d.ts.map +1 -0
  45. package/lib/sheet/BottomSheet.js +89 -0
  46. package/lib/sheet/BottomSheet.js.map +1 -0
  47. package/lib/sheet/PickerSheet.d.ts +26 -0
  48. package/lib/sheet/PickerSheet.d.ts.map +1 -0
  49. package/lib/sheet/PickerSheet.js +27 -0
  50. package/lib/sheet/PickerSheet.js.map +1 -0
  51. package/lib/sheet/sheets.d.ts +48 -0
  52. package/lib/sheet/sheets.d.ts.map +1 -0
  53. package/lib/sheet/sheets.js +58 -0
  54. package/lib/sheet/sheets.js.map +1 -0
  55. package/lib/theme.d.ts +81 -0
  56. package/lib/theme.d.ts.map +1 -0
  57. package/lib/theme.js +96 -0
  58. package/lib/theme.js.map +1 -0
  59. package/lib/time/TimePicker.d.ts +41 -0
  60. package/lib/time/TimePicker.d.ts.map +1 -0
  61. package/lib/time/TimePicker.js +105 -0
  62. package/lib/time/TimePicker.js.map +1 -0
  63. package/lib/types.d.ts +9 -0
  64. package/lib/types.d.ts.map +1 -0
  65. package/lib/types.js +2 -0
  66. package/lib/types.js.map +1 -0
  67. package/lib/utils/OwnScroll.d.ts +10 -0
  68. package/lib/utils/OwnScroll.d.ts.map +1 -0
  69. package/lib/utils/OwnScroll.js +13 -0
  70. package/lib/utils/OwnScroll.js.map +1 -0
  71. package/lib/utils/date.d.ts +30 -0
  72. package/lib/utils/date.d.ts.map +1 -0
  73. package/lib/utils/date.js +115 -0
  74. package/lib/utils/date.js.map +1 -0
  75. package/lib/wheel/MultiWheelPicker.d.ts +26 -0
  76. package/lib/wheel/MultiWheelPicker.d.ts.map +1 -0
  77. package/lib/wheel/MultiWheelPicker.js +28 -0
  78. package/lib/wheel/MultiWheelPicker.js.map +1 -0
  79. package/lib/wheel/WheelPicker.d.ts +59 -0
  80. package/lib/wheel/WheelPicker.d.ts.map +1 -0
  81. package/lib/wheel/WheelPicker.js +257 -0
  82. package/lib/wheel/WheelPicker.js.map +1 -0
  83. package/lib/wheel/typeToSelect.d.ts +8 -0
  84. package/lib/wheel/typeToSelect.d.ts.map +1 -0
  85. package/lib/wheel/typeToSelect.js +10 -0
  86. package/lib/wheel/typeToSelect.js.map +1 -0
  87. package/package.json +81 -4
  88. package/src/date/Calendar.tsx +586 -0
  89. package/src/date/DatePicker.tsx +22 -0
  90. package/src/date/DateWheelPicker.tsx +149 -0
  91. package/src/emoji/EmojiPicker.tsx +410 -0
  92. package/src/emoji/data.ts +3 -0
  93. package/src/emoji/emojis.ts +127 -0
  94. package/src/index.ts +16 -0
  95. package/src/select/SelectList.tsx +266 -0
  96. package/src/select/SelectPicker.tsx +203 -0
  97. package/src/select/filter.ts +17 -0
  98. package/src/sheet/BottomSheet.tsx +199 -0
  99. package/src/sheet/PickerSheet.tsx +86 -0
  100. package/src/sheet/sheets.tsx +218 -0
  101. package/src/theme.tsx +160 -0
  102. package/src/time/TimePicker.tsx +199 -0
  103. package/src/types.ts +10 -0
  104. package/src/utils/OwnScroll.tsx +14 -0
  105. package/src/utils/date.ts +131 -0
  106. package/src/wheel/MultiWheelPicker.tsx +96 -0
  107. package/src/wheel/WheelPicker.tsx +534 -0
  108. 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
+ });