@noya-app/noya-designsystem 0.1.77 → 0.1.79
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/.turbo/turbo-build.log +20 -12
- package/CHANGELOG.md +15 -0
- package/dist/chunk-D57E6H3M.mjs +36 -0
- package/dist/chunk-D57E6H3M.mjs.map +1 -0
- package/dist/chunk-FJ6ZGZIA.mjs +43 -0
- package/dist/chunk-FJ6ZGZIA.mjs.map +1 -0
- package/dist/emojis.d.mts +1 -0
- package/dist/emojis.d.ts +1 -0
- package/dist/emojis.js +31 -0
- package/dist/emojis.js.map +1 -0
- package/dist/emojis.mjs +8 -0
- package/dist/emojis.mjs.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.mts +142 -90
- package/dist/index.d.ts +142 -90
- package/dist/index.js +30559 -6412
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +35081 -10928
- package/dist/index.mjs.map +1 -1
- package/package.json +13 -17
- package/src/__tests__/__snapshots__/fuzzyScorer.test.ts.snap +1 -1
- package/src/components/Avatar.tsx +10 -10
- package/src/components/Banner.tsx +1 -1
- package/src/components/BaseToolbar.tsx +1 -1
- package/src/components/Button.tsx +2 -1
- package/src/components/Chip.tsx +1 -1
- package/src/components/ColorSwatchControl.tsx +22 -14
- package/src/components/CommandPalette.tsx +10 -6
- package/src/components/ContextMenu.tsx +249 -38
- package/src/components/Dialog.tsx +70 -67
- package/src/components/Drawer.tsx +56 -17
- package/src/components/DropdownMenu.tsx +305 -46
- package/src/components/EditableText.tsx +1 -1
- package/src/components/EmojiPicker.tsx +645 -0
- package/src/components/GridView.tsx +5 -1
- package/src/components/IVirtualizedList.tsx +21 -1
- package/src/components/InputField.tsx +2 -10
- package/src/components/InspectorContainer.tsx +4 -2
- package/src/components/Message.tsx +5 -16
- package/src/components/Popover.tsx +73 -107
- package/src/components/Progress.tsx +18 -18
- package/src/components/ScrollArea.tsx +66 -31
- package/src/components/ScrollableSidebar.tsx +1 -1
- package/src/components/SegmentedControl.tsx +166 -38
- package/src/components/SelectMenu.tsx +193 -101
- package/src/components/Slider.tsx +40 -38
- package/src/components/Switch.tsx +4 -4
- package/src/components/TextArea.tsx +1 -1
- package/src/components/Toast.tsx +99 -26
- package/src/components/Toolbar.tsx +114 -16
- package/src/components/Tooltip.tsx +18 -8
- package/src/components/Virtualized.tsx +193 -14
- package/src/components/VisuallyHidden.tsx +20 -0
- package/src/components/__tests__/Virtualized.math.test.ts +426 -1
- package/src/components/__tests__/Virtualized.test.tsx +129 -1
- package/src/components/ai-assistant/AIAssistantLayout.tsx +11 -6
- package/src/components/internal/Menu.tsx +4 -0
- package/src/components/listView/ListViewEditableRowTitle.tsx +1 -1
- package/src/components/listView/ListViewRoot.tsx +5 -1
- package/src/components/resizablePanels/Panel.tsx +94 -0
- package/src/components/resizablePanels/PanelGroup.tsx +498 -0
- package/src/components/resizablePanels/PanelGroupContext.tsx +14 -0
- package/src/components/resizablePanels/PanelResizeHandle.tsx +61 -0
- package/src/components/resizablePanels/index.ts +7 -0
- package/src/components/resizablePanels/types.ts +65 -0
- package/src/components/workspace/DrawerWorkspaceLayout.tsx +22 -7
- package/src/components/workspace/PanelWorkspaceLayout.tsx +30 -84
- package/src/components/workspace/WorkspaceLayout.tsx +28 -58
- package/src/components/workspace/types.ts +6 -4
- package/src/contexts/DialogContext.tsx +15 -24
- package/src/emojis.ts +1 -0
- package/src/hooks/useTriggerToggle.ts +95 -0
- package/src/index.css +2 -0
- package/src/index.tsx +1 -2
- package/src/theme/proseTheme.ts +2 -0
- package/src/utils/mergeProps.ts +57 -0
- package/src/utils/skinTone.ts +90 -0
- package/tsup.config.ts +3 -1
- package/src/__tests__/workspaceLayout.test.ts +0 -281
- package/src/components/ScrollArea2.tsx +0 -76
- package/src/components/internal/MenuViewport.tsx +0 -178
- package/src/components/internal/SelectItem.tsx +0 -138
- package/src/components/workspace/panelStorage.ts +0 -216
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ClockIcon,
|
|
5
|
+
CookieIcon,
|
|
6
|
+
FaceIcon,
|
|
7
|
+
FlagIcon,
|
|
8
|
+
HomeIcon,
|
|
9
|
+
LightningBoltIcon,
|
|
10
|
+
MagnifyingGlassIcon,
|
|
11
|
+
MapPinIcon,
|
|
12
|
+
PersonIcon,
|
|
13
|
+
StarIcon,
|
|
14
|
+
SunIcon,
|
|
15
|
+
} from "@noya-app/noya-icons";
|
|
16
|
+
import type { Gemoji } from "gemoji";
|
|
17
|
+
import React, {
|
|
18
|
+
memo,
|
|
19
|
+
useCallback,
|
|
20
|
+
useEffect,
|
|
21
|
+
useMemo,
|
|
22
|
+
useRef,
|
|
23
|
+
useState,
|
|
24
|
+
} from "react";
|
|
25
|
+
import { cx } from "../utils/classNames";
|
|
26
|
+
import {
|
|
27
|
+
SKIN_TONES,
|
|
28
|
+
SKIN_TONE_DISPLAY,
|
|
29
|
+
SkinTone,
|
|
30
|
+
applySkinTone,
|
|
31
|
+
} from "../utils/skinTone";
|
|
32
|
+
import { Divider } from "./Divider";
|
|
33
|
+
import { DropdownMenu } from "./DropdownMenu";
|
|
34
|
+
import { InputField } from "./InputField";
|
|
35
|
+
import { IVirtualizedList } from "./IVirtualizedList";
|
|
36
|
+
import { SegmentedControl } from "./SegmentedControl";
|
|
37
|
+
import { Virtualized } from "./Virtualized";
|
|
38
|
+
|
|
39
|
+
export type { Gemoji };
|
|
40
|
+
|
|
41
|
+
// Storage interface for persisting skin tone preference
|
|
42
|
+
export interface EmojiPickerStorage {
|
|
43
|
+
getSkinTone: () => SkinTone | null;
|
|
44
|
+
setSkinTone: (tone: SkinTone) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const SKIN_TONE_STORAGE_KEY = "emoji-picker-skin-tone";
|
|
48
|
+
|
|
49
|
+
// Default localStorage implementation with SSR safety
|
|
50
|
+
const defaultStorage: EmojiPickerStorage = {
|
|
51
|
+
getSkinTone: () => {
|
|
52
|
+
if (typeof localStorage === "undefined") return null;
|
|
53
|
+
try {
|
|
54
|
+
const value = localStorage.getItem(SKIN_TONE_STORAGE_KEY);
|
|
55
|
+
if (value && Object.keys(SKIN_TONES).includes(value)) {
|
|
56
|
+
return value as SkinTone;
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
// localStorage may throw in some environments
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
},
|
|
63
|
+
setSkinTone: (tone: SkinTone) => {
|
|
64
|
+
if (typeof localStorage === "undefined") return;
|
|
65
|
+
try {
|
|
66
|
+
localStorage.setItem(SKIN_TONE_STORAGE_KEY, tone);
|
|
67
|
+
} catch {
|
|
68
|
+
// localStorage may throw in some environments
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const emojiCategories = [
|
|
74
|
+
"Smileys & Emotion",
|
|
75
|
+
"People & Body",
|
|
76
|
+
"Animals & Nature",
|
|
77
|
+
"Food & Drink",
|
|
78
|
+
"Travel & Places",
|
|
79
|
+
"Activities",
|
|
80
|
+
"Objects",
|
|
81
|
+
"Symbols",
|
|
82
|
+
"Flags",
|
|
83
|
+
] as const;
|
|
84
|
+
|
|
85
|
+
type EmojiCategory = (typeof emojiCategories)[number];
|
|
86
|
+
|
|
87
|
+
const EMOJI_SIZE = 32;
|
|
88
|
+
const MIN_EMOJI_GAP = 4;
|
|
89
|
+
const SECTION_HEADER_HEIGHT = 24;
|
|
90
|
+
const HEADER_HEIGHT = 80; // Search + category tabs
|
|
91
|
+
const FOOTER_HEIGHT = 40;
|
|
92
|
+
const HORIZONTAL_PADDING = 8; // n-px-2 = 8px each side
|
|
93
|
+
|
|
94
|
+
// Category icons mapping
|
|
95
|
+
const categoryIcons: Record<
|
|
96
|
+
EmojiCategory | "search" | "frequent",
|
|
97
|
+
React.ReactElement
|
|
98
|
+
> = {
|
|
99
|
+
search: <MagnifyingGlassIcon />,
|
|
100
|
+
frequent: <ClockIcon />,
|
|
101
|
+
"Smileys & Emotion": <FaceIcon />,
|
|
102
|
+
"People & Body": <PersonIcon />,
|
|
103
|
+
"Animals & Nature": <SunIcon />,
|
|
104
|
+
"Food & Drink": <CookieIcon />,
|
|
105
|
+
"Travel & Places": <MapPinIcon />,
|
|
106
|
+
Activities: <LightningBoltIcon />,
|
|
107
|
+
Objects: <HomeIcon />,
|
|
108
|
+
Symbols: <StarIcon />,
|
|
109
|
+
Flags: <FlagIcon />,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
interface EmojiPickerProps {
|
|
113
|
+
size: { width: number; height: number };
|
|
114
|
+
emojis: Gemoji[];
|
|
115
|
+
onSelect: (emoji: string) => void;
|
|
116
|
+
storage?: EmojiPickerStorage;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Row types for virtualized list
|
|
120
|
+
type EmojiRow =
|
|
121
|
+
| { type: "header"; category: string }
|
|
122
|
+
| { type: "emojis"; emojis: Gemoji[]; startIndex: number };
|
|
123
|
+
|
|
124
|
+
function buildRows(
|
|
125
|
+
emojis: Gemoji[],
|
|
126
|
+
itemsPerRow: number,
|
|
127
|
+
groupByCategory: boolean,
|
|
128
|
+
frequentlyUsed: string[]
|
|
129
|
+
): EmojiRow[] {
|
|
130
|
+
const rows: EmojiRow[] = [];
|
|
131
|
+
|
|
132
|
+
if (groupByCategory) {
|
|
133
|
+
// Add frequently used section if any
|
|
134
|
+
if (frequentlyUsed.length > 0) {
|
|
135
|
+
rows.push({ type: "header", category: "Frequently Used" });
|
|
136
|
+
const frequentEmojis = frequentlyUsed
|
|
137
|
+
.map((e) => emojis.find((em) => em.emoji === e))
|
|
138
|
+
.filter((e): e is Gemoji => e !== undefined);
|
|
139
|
+
for (let i = 0; i < frequentEmojis.length; i += itemsPerRow) {
|
|
140
|
+
rows.push({
|
|
141
|
+
type: "emojis",
|
|
142
|
+
emojis: frequentEmojis.slice(i, i + itemsPerRow),
|
|
143
|
+
startIndex: i,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Group by category
|
|
149
|
+
for (const category of emojiCategories) {
|
|
150
|
+
const categoryEmojis = emojis.filter((e) => e.category === category);
|
|
151
|
+
if (categoryEmojis.length === 0) continue;
|
|
152
|
+
|
|
153
|
+
rows.push({ type: "header", category });
|
|
154
|
+
for (let i = 0; i < categoryEmojis.length; i += itemsPerRow) {
|
|
155
|
+
rows.push({
|
|
156
|
+
type: "emojis",
|
|
157
|
+
emojis: categoryEmojis.slice(i, i + itemsPerRow),
|
|
158
|
+
startIndex: i,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
} else {
|
|
163
|
+
// Flat list for search results
|
|
164
|
+
for (let i = 0; i < emojis.length; i += itemsPerRow) {
|
|
165
|
+
rows.push({
|
|
166
|
+
type: "emojis",
|
|
167
|
+
emojis: emojis.slice(i, i + itemsPerRow),
|
|
168
|
+
startIndex: i,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return rows;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function filterEmojis(emojis: Gemoji[], query: string): Gemoji[] {
|
|
177
|
+
if (!query) return emojis;
|
|
178
|
+
|
|
179
|
+
const lowerQuery = query.toLowerCase();
|
|
180
|
+
return emojis.filter(
|
|
181
|
+
(e) =>
|
|
182
|
+
e.names.some((n) => n.toLowerCase().includes(lowerQuery)) ||
|
|
183
|
+
e.tags?.some((t) => t.toLowerCase().includes(lowerQuery)) ||
|
|
184
|
+
e.description?.toLowerCase().includes(lowerQuery)
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const EmojiButton = memo(function EmojiButton({
|
|
189
|
+
emoji,
|
|
190
|
+
displayEmoji,
|
|
191
|
+
onSelect,
|
|
192
|
+
onHover,
|
|
193
|
+
isSelected,
|
|
194
|
+
buttonRef,
|
|
195
|
+
}: {
|
|
196
|
+
emoji: Gemoji;
|
|
197
|
+
displayEmoji: string;
|
|
198
|
+
onSelect: (emoji: string) => void;
|
|
199
|
+
onHover: (emoji: Gemoji | null) => void;
|
|
200
|
+
isSelected?: boolean;
|
|
201
|
+
buttonRef?: React.Ref<HTMLButtonElement>;
|
|
202
|
+
}) {
|
|
203
|
+
return (
|
|
204
|
+
<button
|
|
205
|
+
ref={buttonRef}
|
|
206
|
+
type="button"
|
|
207
|
+
className={cx(
|
|
208
|
+
"n-flex n-items-center n-justify-center n-rounded n-cursor-pointer n-border-0 n-bg-transparent",
|
|
209
|
+
isSelected
|
|
210
|
+
? "n-bg-primary/20 n-ring-2 n-ring-primary"
|
|
211
|
+
: "hover:n-bg-button-background-hover active:n-bg-button-background-active"
|
|
212
|
+
)}
|
|
213
|
+
style={{ width: EMOJI_SIZE, height: EMOJI_SIZE, fontSize: 22 }}
|
|
214
|
+
onClick={() => onSelect(emoji.emoji)}
|
|
215
|
+
onMouseEnter={() => onHover(emoji)}
|
|
216
|
+
onMouseLeave={() => onHover(null)}
|
|
217
|
+
title={`:${emoji.names[0]}:`}
|
|
218
|
+
tabIndex={-1}
|
|
219
|
+
>
|
|
220
|
+
{displayEmoji}
|
|
221
|
+
</button>
|
|
222
|
+
);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
export const EmojiPicker = memo(function EmojiPicker({
|
|
226
|
+
size,
|
|
227
|
+
emojis,
|
|
228
|
+
onSelect,
|
|
229
|
+
storage = defaultStorage,
|
|
230
|
+
}: EmojiPickerProps) {
|
|
231
|
+
const [search, setSearch] = useState("");
|
|
232
|
+
const [hoveredEmoji, setHoveredEmoji] = useState<Gemoji | null>(null);
|
|
233
|
+
const [frequentlyUsed, setFrequentlyUsed] = useState<string[]>([]);
|
|
234
|
+
const [activeCategory, setActiveCategory] = useState<string | null>(null);
|
|
235
|
+
const [skinTone, _setSkinTone] = useState<SkinTone>(
|
|
236
|
+
() => storage.getSkinTone() ?? "default"
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
const setSkinTone = useCallback(
|
|
240
|
+
(tone: SkinTone) => {
|
|
241
|
+
_setSkinTone(tone);
|
|
242
|
+
storage.setSkinTone(tone);
|
|
243
|
+
},
|
|
244
|
+
[storage]
|
|
245
|
+
);
|
|
246
|
+
// Selected emoji index for keyboard navigation (0 = first emoji by default)
|
|
247
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
248
|
+
const virtualizedRef = useRef<IVirtualizedList>(null);
|
|
249
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
250
|
+
|
|
251
|
+
// Content width excluding horizontal padding (8px each side)
|
|
252
|
+
const contentWidth = size.width - HORIZONTAL_PADDING * 2;
|
|
253
|
+
// Calculate how many emojis fit with minimum gap
|
|
254
|
+
const itemsPerRow = Math.floor(
|
|
255
|
+
(contentWidth + MIN_EMOJI_GAP) / (EMOJI_SIZE + MIN_EMOJI_GAP)
|
|
256
|
+
);
|
|
257
|
+
// Calculate actual gap to distribute remaining space evenly
|
|
258
|
+
const emojiGap =
|
|
259
|
+
itemsPerRow > 1
|
|
260
|
+
? (contentWidth - itemsPerRow * EMOJI_SIZE) / (itemsPerRow - 1)
|
|
261
|
+
: 0;
|
|
262
|
+
const gridHeight = size.height - HEADER_HEIGHT - FOOTER_HEIGHT;
|
|
263
|
+
|
|
264
|
+
const filteredEmojis = useMemo(
|
|
265
|
+
() => filterEmojis(emojis, search),
|
|
266
|
+
[emojis, search]
|
|
267
|
+
);
|
|
268
|
+
const isSearching = search.length > 0;
|
|
269
|
+
|
|
270
|
+
const rows = useMemo(
|
|
271
|
+
() => buildRows(filteredEmojis, itemsPerRow, !isSearching, frequentlyUsed),
|
|
272
|
+
[filteredEmojis, itemsPerRow, isSearching, frequentlyUsed]
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
// Build flat list of emojis for keyboard navigation
|
|
276
|
+
const flatEmojiList = useMemo(() => {
|
|
277
|
+
const list: Gemoji[] = [];
|
|
278
|
+
for (const row of rows) {
|
|
279
|
+
if (row.type === "emojis") {
|
|
280
|
+
list.push(...row.emojis);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return list;
|
|
284
|
+
}, [rows]);
|
|
285
|
+
|
|
286
|
+
// Reset selection to first emoji when search changes
|
|
287
|
+
useEffect(() => {
|
|
288
|
+
setSelectedIndex(0);
|
|
289
|
+
}, [search]);
|
|
290
|
+
|
|
291
|
+
// Get selected emoji for footer display
|
|
292
|
+
const selectedEmoji =
|
|
293
|
+
selectedIndex >= 0 ? flatEmojiList[selectedIndex] : null;
|
|
294
|
+
|
|
295
|
+
// Build category -> row index mapping for scrolling
|
|
296
|
+
const categoryRowIndices = useMemo(() => {
|
|
297
|
+
const indices: Record<string, number> = {};
|
|
298
|
+
rows.forEach((row, index) => {
|
|
299
|
+
if (row.type === "header" && !(row.category in indices)) {
|
|
300
|
+
indices[row.category] = index;
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
return indices;
|
|
304
|
+
}, [rows]);
|
|
305
|
+
|
|
306
|
+
const handleSelect = useCallback(
|
|
307
|
+
(emoji: string) => {
|
|
308
|
+
const emojiWithSkinTone = applySkinTone(emoji, skinTone);
|
|
309
|
+
onSelect(emojiWithSkinTone);
|
|
310
|
+
// Add to frequently used (keep max 16, most recent first)
|
|
311
|
+
// Store the base emoji without skin tone for consistent lookup
|
|
312
|
+
setFrequentlyUsed((prev) => {
|
|
313
|
+
const filtered = prev.filter((e) => e !== emoji);
|
|
314
|
+
return [emoji, ...filtered].slice(0, 16);
|
|
315
|
+
});
|
|
316
|
+
},
|
|
317
|
+
[onSelect, skinTone]
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
// Find which row contains a given flat emoji index
|
|
321
|
+
const getRowIndexForEmojiIndex = useCallback(
|
|
322
|
+
(emojiIndex: number) => {
|
|
323
|
+
let count = 0;
|
|
324
|
+
for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
|
|
325
|
+
const row = rows[rowIndex];
|
|
326
|
+
if (row.type === "emojis") {
|
|
327
|
+
if (count + row.emojis.length > emojiIndex) {
|
|
328
|
+
return rowIndex;
|
|
329
|
+
}
|
|
330
|
+
count += row.emojis.length;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return 0;
|
|
334
|
+
},
|
|
335
|
+
[rows]
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
// Scroll to make selected emoji visible
|
|
339
|
+
useEffect(() => {
|
|
340
|
+
if (selectedIndex < 0) return;
|
|
341
|
+
if (!virtualizedRef.current) return;
|
|
342
|
+
|
|
343
|
+
const rowIndex = getRowIndexForEmojiIndex(selectedIndex);
|
|
344
|
+
virtualizedRef.current.scrollToIndex(rowIndex, {
|
|
345
|
+
padding: 2,
|
|
346
|
+
});
|
|
347
|
+
}, [selectedIndex, getRowIndexForEmojiIndex]);
|
|
348
|
+
|
|
349
|
+
const handleKeyDown = useCallback(
|
|
350
|
+
(event: React.KeyboardEvent) => {
|
|
351
|
+
const totalEmojis = flatEmojiList.length;
|
|
352
|
+
if (totalEmojis === 0) return;
|
|
353
|
+
|
|
354
|
+
switch (event.key) {
|
|
355
|
+
case "ArrowDown": {
|
|
356
|
+
event.preventDefault();
|
|
357
|
+
// Move down one row
|
|
358
|
+
const newIndex = Math.min(
|
|
359
|
+
selectedIndex + itemsPerRow,
|
|
360
|
+
totalEmojis - 1
|
|
361
|
+
);
|
|
362
|
+
setSelectedIndex(newIndex);
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
case "ArrowUp": {
|
|
366
|
+
event.preventDefault();
|
|
367
|
+
// Move up one row (stop at first row)
|
|
368
|
+
if (selectedIndex >= itemsPerRow) {
|
|
369
|
+
setSelectedIndex(selectedIndex - itemsPerRow);
|
|
370
|
+
}
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
case "ArrowRight": {
|
|
374
|
+
// If we can move right in grid, do it; otherwise let input handle it
|
|
375
|
+
if (selectedIndex < totalEmojis - 1) {
|
|
376
|
+
event.preventDefault();
|
|
377
|
+
setSelectedIndex(selectedIndex + 1);
|
|
378
|
+
}
|
|
379
|
+
// else: let the input cursor move naturally
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
case "ArrowLeft": {
|
|
383
|
+
// Prioritize grid navigation; only let cursor move if at index 0
|
|
384
|
+
if (selectedIndex > 0) {
|
|
385
|
+
event.preventDefault();
|
|
386
|
+
setSelectedIndex(selectedIndex - 1);
|
|
387
|
+
}
|
|
388
|
+
// else: at index 0, let the input cursor move naturally
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
case "Enter": {
|
|
392
|
+
if (selectedIndex >= 0 && flatEmojiList[selectedIndex]) {
|
|
393
|
+
event.preventDefault();
|
|
394
|
+
handleSelect(flatEmojiList[selectedIndex].emoji);
|
|
395
|
+
}
|
|
396
|
+
break;
|
|
397
|
+
}
|
|
398
|
+
case "Escape": {
|
|
399
|
+
// Clear search and reset selection
|
|
400
|
+
if (search) {
|
|
401
|
+
event.preventDefault();
|
|
402
|
+
setSearch("");
|
|
403
|
+
setSelectedIndex(0);
|
|
404
|
+
}
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
},
|
|
409
|
+
[selectedIndex, itemsPerRow, flatEmojiList, handleSelect, search]
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
const handleCategoryChange = useCallback(
|
|
413
|
+
(category: string) => {
|
|
414
|
+
if (category === "search") {
|
|
415
|
+
inputRef.current?.focus();
|
|
416
|
+
setActiveCategory(null);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
setActiveCategory(category);
|
|
421
|
+
setSearch(""); // Clear search when selecting category
|
|
422
|
+
|
|
423
|
+
// Find the row index for this category and scroll to it
|
|
424
|
+
const categoryName =
|
|
425
|
+
category === "frequent" ? "Frequently Used" : category;
|
|
426
|
+
const rowIndex = categoryRowIndices[categoryName];
|
|
427
|
+
if (rowIndex !== undefined && virtualizedRef.current) {
|
|
428
|
+
virtualizedRef.current.scrollToIndex(rowIndex, {
|
|
429
|
+
align: "start",
|
|
430
|
+
padding: 2,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
},
|
|
434
|
+
[categoryRowIndices]
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
const getRowHeight = useCallback(
|
|
438
|
+
(index: number) => {
|
|
439
|
+
const row = rows[index];
|
|
440
|
+
return row?.type === "header" ? SECTION_HEADER_HEIGHT : EMOJI_SIZE;
|
|
441
|
+
},
|
|
442
|
+
[rows]
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
// Build a map from emoji to its flat index for selection highlighting
|
|
446
|
+
const emojiToFlatIndex = useMemo(() => {
|
|
447
|
+
const map = new Map<string, number>();
|
|
448
|
+
flatEmojiList.forEach((emoji, index) => {
|
|
449
|
+
map.set(emoji.emoji, index);
|
|
450
|
+
});
|
|
451
|
+
return map;
|
|
452
|
+
}, [flatEmojiList]);
|
|
453
|
+
|
|
454
|
+
const renderRow = useCallback(
|
|
455
|
+
(index: number) => {
|
|
456
|
+
const row = rows[index];
|
|
457
|
+
if (!row) return null;
|
|
458
|
+
|
|
459
|
+
if (row.type === "header") {
|
|
460
|
+
return (
|
|
461
|
+
<div
|
|
462
|
+
className="n-text-xs n-font-medium n-text-text-muted n-px-2 n-pb-1 n-flex n-items-end n-bg-popover-background"
|
|
463
|
+
style={{ height: SECTION_HEADER_HEIGHT }}
|
|
464
|
+
>
|
|
465
|
+
{row.category}
|
|
466
|
+
</div>
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return (
|
|
471
|
+
<div
|
|
472
|
+
className="n-grid n-px-2"
|
|
473
|
+
style={{
|
|
474
|
+
gridTemplateColumns: `repeat(${itemsPerRow}, ${EMOJI_SIZE}px)`,
|
|
475
|
+
gap: emojiGap,
|
|
476
|
+
}}
|
|
477
|
+
>
|
|
478
|
+
{row.emojis.map((emoji) => {
|
|
479
|
+
const flatIndex = emojiToFlatIndex.get(emoji.emoji) ?? -1;
|
|
480
|
+
return (
|
|
481
|
+
<EmojiButton
|
|
482
|
+
key={emoji.emoji}
|
|
483
|
+
emoji={emoji}
|
|
484
|
+
displayEmoji={applySkinTone(emoji.emoji, skinTone)}
|
|
485
|
+
onSelect={handleSelect}
|
|
486
|
+
onHover={setHoveredEmoji}
|
|
487
|
+
isSelected={flatIndex === selectedIndex}
|
|
488
|
+
/>
|
|
489
|
+
);
|
|
490
|
+
})}
|
|
491
|
+
</div>
|
|
492
|
+
);
|
|
493
|
+
},
|
|
494
|
+
[
|
|
495
|
+
rows,
|
|
496
|
+
handleSelect,
|
|
497
|
+
itemsPerRow,
|
|
498
|
+
emojiGap,
|
|
499
|
+
emojiToFlatIndex,
|
|
500
|
+
selectedIndex,
|
|
501
|
+
skinTone,
|
|
502
|
+
]
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
// Category tabs items
|
|
506
|
+
const categoryItems = useMemo(
|
|
507
|
+
() => [
|
|
508
|
+
{
|
|
509
|
+
value: "search",
|
|
510
|
+
icon: categoryIcons.search,
|
|
511
|
+
title: "",
|
|
512
|
+
tooltip: "Search",
|
|
513
|
+
},
|
|
514
|
+
...(frequentlyUsed.length > 0
|
|
515
|
+
? [
|
|
516
|
+
{
|
|
517
|
+
value: "frequent",
|
|
518
|
+
icon: categoryIcons.frequent,
|
|
519
|
+
title: "",
|
|
520
|
+
tooltip: "Frequently Used",
|
|
521
|
+
},
|
|
522
|
+
]
|
|
523
|
+
: []),
|
|
524
|
+
...emojiCategories.map((cat) => ({
|
|
525
|
+
value: cat,
|
|
526
|
+
icon: categoryIcons[cat],
|
|
527
|
+
title: "",
|
|
528
|
+
tooltip: cat,
|
|
529
|
+
})),
|
|
530
|
+
],
|
|
531
|
+
[frequentlyUsed.length]
|
|
532
|
+
);
|
|
533
|
+
|
|
534
|
+
// Show hovered emoji, or selected emoji if nothing hovered
|
|
535
|
+
const displayEmoji = hoveredEmoji ?? selectedEmoji;
|
|
536
|
+
const footerText = displayEmoji
|
|
537
|
+
? `:${displayEmoji.names[0]}:${displayEmoji.names.length > 1 ? ` :${displayEmoji.names[1]}:` : ""}`
|
|
538
|
+
: "";
|
|
539
|
+
|
|
540
|
+
// Skin tone picker menu items
|
|
541
|
+
const skinToneMenuItems = useMemo(
|
|
542
|
+
() =>
|
|
543
|
+
(Object.keys(SKIN_TONES) as SkinTone[]).map((tone) => ({
|
|
544
|
+
value: tone,
|
|
545
|
+
title: (
|
|
546
|
+
<span className="n-flex n-items-center n-gap-2">
|
|
547
|
+
<span style={{ fontSize: 18 }}>{SKIN_TONE_DISPLAY[tone]}</span>
|
|
548
|
+
<span className="n-capitalize">
|
|
549
|
+
{tone === "default" ? "Default" : tone.replace("-", " ")}
|
|
550
|
+
</span>
|
|
551
|
+
</span>
|
|
552
|
+
),
|
|
553
|
+
checked: skinTone === tone,
|
|
554
|
+
})),
|
|
555
|
+
[skinTone]
|
|
556
|
+
);
|
|
557
|
+
|
|
558
|
+
return (
|
|
559
|
+
<div
|
|
560
|
+
className="n-flex n-flex-col n-bg-popover-background n-outline-none"
|
|
561
|
+
style={{ width: size.width, height: size.height }}
|
|
562
|
+
>
|
|
563
|
+
<SegmentedControl
|
|
564
|
+
variant="tabs"
|
|
565
|
+
items={categoryItems}
|
|
566
|
+
value={activeCategory ?? "search"}
|
|
567
|
+
onValueChange={handleCategoryChange}
|
|
568
|
+
allowEmpty
|
|
569
|
+
className="n-flex-nowrap n-px-2"
|
|
570
|
+
// className="n-overflow-x-auto n-flex-nowrap n-px-2"
|
|
571
|
+
/>
|
|
572
|
+
<Divider />
|
|
573
|
+
{/* Header: Search + Category Tabs */}
|
|
574
|
+
<div className="n-flex n-flex-col n-gap-2 n-p-2">
|
|
575
|
+
<InputField.Root>
|
|
576
|
+
<InputField.Input
|
|
577
|
+
ref={inputRef}
|
|
578
|
+
value={search}
|
|
579
|
+
onChange={setSearch}
|
|
580
|
+
onKeyDown={handleKeyDown}
|
|
581
|
+
placeholder="Search all emoji"
|
|
582
|
+
className="n-text-sm"
|
|
583
|
+
autoFocus
|
|
584
|
+
/>
|
|
585
|
+
</InputField.Root>
|
|
586
|
+
</div>
|
|
587
|
+
{/* Emoji Grid */}
|
|
588
|
+
<div className="n-flex-1 n-overflow-hidden">
|
|
589
|
+
{rows.length > 0 ? (
|
|
590
|
+
<Virtualized
|
|
591
|
+
ref={virtualizedRef}
|
|
592
|
+
height={gridHeight}
|
|
593
|
+
itemCount={rows.length}
|
|
594
|
+
getItemHeight={getRowHeight}
|
|
595
|
+
renderItem={renderRow}
|
|
596
|
+
overscan={4}
|
|
597
|
+
padding={{ top: 2, bottom: 2 }}
|
|
598
|
+
useScrollArea
|
|
599
|
+
isStickyRow={(index) => rows[index].type === "header"}
|
|
600
|
+
/>
|
|
601
|
+
) : (
|
|
602
|
+
<div className="n-flex n-items-center n-justify-center n-h-full n-text-text-muted n-text-sm">
|
|
603
|
+
No emoji found
|
|
604
|
+
</div>
|
|
605
|
+
)}
|
|
606
|
+
</div>
|
|
607
|
+
{/* Footer */}
|
|
608
|
+
<div
|
|
609
|
+
className={cx(
|
|
610
|
+
"n-flex n-items-center n-px-2 n-border-t n-border-b n-border-t-divider n-border-b-transparent n-text-sm n-text-text-muted n-bg-input-background-light"
|
|
611
|
+
)}
|
|
612
|
+
style={{ height: FOOTER_HEIGHT, gap: emojiGap }}
|
|
613
|
+
>
|
|
614
|
+
{displayEmoji && (
|
|
615
|
+
<>
|
|
616
|
+
<span
|
|
617
|
+
className="n-flex n-items-center n-justify-center n-shrink-0"
|
|
618
|
+
style={{ width: EMOJI_SIZE, height: EMOJI_SIZE, fontSize: 22 }}
|
|
619
|
+
>
|
|
620
|
+
{applySkinTone(displayEmoji.emoji, skinTone)}
|
|
621
|
+
</span>
|
|
622
|
+
<span className="n-truncate n-flex-1">{footerText}</span>
|
|
623
|
+
</>
|
|
624
|
+
)}
|
|
625
|
+
{!displayEmoji && <span className="n-flex-1"> </span>}
|
|
626
|
+
<DropdownMenu
|
|
627
|
+
items={skinToneMenuItems}
|
|
628
|
+
onSelect={(tone) => setSkinTone(tone as SkinTone)}
|
|
629
|
+
side="top"
|
|
630
|
+
align="end"
|
|
631
|
+
sideOffset={8}
|
|
632
|
+
>
|
|
633
|
+
<button
|
|
634
|
+
type="button"
|
|
635
|
+
className="n-flex n-items-center n-justify-center n-rounded n-cursor-pointer n-border-0 n-bg-transparent hover:n-bg-button-background-hover active:n-bg-button-background-active n-shrink-0"
|
|
636
|
+
style={{ width: EMOJI_SIZE, height: EMOJI_SIZE, fontSize: 18 }}
|
|
637
|
+
title="Choose skin tone"
|
|
638
|
+
>
|
|
639
|
+
{SKIN_TONE_DISPLAY[skinTone]}
|
|
640
|
+
</button>
|
|
641
|
+
</DropdownMenu>
|
|
642
|
+
</div>
|
|
643
|
+
</div>
|
|
644
|
+
);
|
|
645
|
+
});
|
|
@@ -410,7 +410,11 @@ const GridViewRoot = forwardRef(function GridViewRoot(
|
|
|
410
410
|
{...props}
|
|
411
411
|
ref={forwardedRef}
|
|
412
412
|
>
|
|
413
|
-
{scrollable ?
|
|
413
|
+
{scrollable ? (
|
|
414
|
+
<ScrollArea className="n-flex-1 n-min-h-0">{content}</ScrollArea>
|
|
415
|
+
) : (
|
|
416
|
+
content
|
|
417
|
+
)}
|
|
414
418
|
</div>
|
|
415
419
|
</GridViewContext.Provider>
|
|
416
420
|
);
|
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
export type ScrollToIndexAlign = "auto" | "start" | "center" | "end";
|
|
4
|
+
|
|
5
|
+
export type ScrollToIndexPadding =
|
|
6
|
+
| number
|
|
7
|
+
| {
|
|
8
|
+
top?: number;
|
|
9
|
+
bottom?: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export interface ScrollToIndexOptions {
|
|
13
|
+
align?: ScrollToIndexAlign;
|
|
14
|
+
/**
|
|
15
|
+
* Extra padding to include when scrolling. When scrolling to "start",
|
|
16
|
+
* this adds space above the item. When scrolling to "end", this adds
|
|
17
|
+
* space below. For "auto", it applies the appropriate padding based
|
|
18
|
+
* on scroll direction. A number applies to both directions.
|
|
19
|
+
*/
|
|
20
|
+
padding?: ScrollToIndexPadding;
|
|
21
|
+
}
|
|
22
|
+
|
|
3
23
|
export interface IVirtualizedList {
|
|
4
|
-
scrollToIndex(index: number): void;
|
|
24
|
+
scrollToIndex(index: number, options?: ScrollToIndexOptions): void;
|
|
5
25
|
}
|
|
@@ -61,8 +61,8 @@ export const getFieldSpacing = ({
|
|
|
61
61
|
startWidth?: number;
|
|
62
62
|
variant?: InputFieldVariant;
|
|
63
63
|
}) => ({
|
|
64
|
-
paddingLeft: `${variant === "bare" ? startWidth : startWidth ? startWidth + 12 :
|
|
65
|
-
paddingRight: `${variant === "bare" ? endWidth : endWidth ? endWidth + 12 :
|
|
64
|
+
paddingLeft: `${variant === "bare" ? startWidth : startWidth ? startWidth + 12 : 8}px`,
|
|
65
|
+
paddingRight: `${variant === "bare" ? endWidth : endWidth ? endWidth + 12 : 8}px`,
|
|
66
66
|
});
|
|
67
67
|
|
|
68
68
|
/* ----------------------------------------------------------------------------
|
|
@@ -663,14 +663,6 @@ function InputFieldRoot({
|
|
|
663
663
|
trigger={rootElement}
|
|
664
664
|
sideOffset={sideOffset}
|
|
665
665
|
showArrow={false}
|
|
666
|
-
onOpenAutoFocus={(event) => {
|
|
667
|
-
event.preventDefault();
|
|
668
|
-
event.stopPropagation();
|
|
669
|
-
}}
|
|
670
|
-
onCloseAutoFocus={(event) => {
|
|
671
|
-
event.preventDefault();
|
|
672
|
-
event.stopPropagation();
|
|
673
|
-
}}
|
|
674
666
|
>
|
|
675
667
|
{measuredWidthObject && (
|
|
676
668
|
<div
|