@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.
Files changed (83) hide show
  1. package/.turbo/turbo-build.log +20 -12
  2. package/CHANGELOG.md +15 -0
  3. package/dist/chunk-D57E6H3M.mjs +36 -0
  4. package/dist/chunk-D57E6H3M.mjs.map +1 -0
  5. package/dist/chunk-FJ6ZGZIA.mjs +43 -0
  6. package/dist/chunk-FJ6ZGZIA.mjs.map +1 -0
  7. package/dist/emojis.d.mts +1 -0
  8. package/dist/emojis.d.ts +1 -0
  9. package/dist/emojis.js +31 -0
  10. package/dist/emojis.js.map +1 -0
  11. package/dist/emojis.mjs +8 -0
  12. package/dist/emojis.mjs.map +1 -0
  13. package/dist/index.css +1 -1
  14. package/dist/index.d.mts +142 -90
  15. package/dist/index.d.ts +142 -90
  16. package/dist/index.js +30559 -6412
  17. package/dist/index.js.map +1 -1
  18. package/dist/index.mjs +35081 -10928
  19. package/dist/index.mjs.map +1 -1
  20. package/package.json +13 -17
  21. package/src/__tests__/__snapshots__/fuzzyScorer.test.ts.snap +1 -1
  22. package/src/components/Avatar.tsx +10 -10
  23. package/src/components/Banner.tsx +1 -1
  24. package/src/components/BaseToolbar.tsx +1 -1
  25. package/src/components/Button.tsx +2 -1
  26. package/src/components/Chip.tsx +1 -1
  27. package/src/components/ColorSwatchControl.tsx +22 -14
  28. package/src/components/CommandPalette.tsx +10 -6
  29. package/src/components/ContextMenu.tsx +249 -38
  30. package/src/components/Dialog.tsx +70 -67
  31. package/src/components/Drawer.tsx +56 -17
  32. package/src/components/DropdownMenu.tsx +305 -46
  33. package/src/components/EditableText.tsx +1 -1
  34. package/src/components/EmojiPicker.tsx +645 -0
  35. package/src/components/GridView.tsx +5 -1
  36. package/src/components/IVirtualizedList.tsx +21 -1
  37. package/src/components/InputField.tsx +2 -10
  38. package/src/components/InspectorContainer.tsx +4 -2
  39. package/src/components/Message.tsx +5 -16
  40. package/src/components/Popover.tsx +73 -107
  41. package/src/components/Progress.tsx +18 -18
  42. package/src/components/ScrollArea.tsx +66 -31
  43. package/src/components/ScrollableSidebar.tsx +1 -1
  44. package/src/components/SegmentedControl.tsx +166 -38
  45. package/src/components/SelectMenu.tsx +193 -101
  46. package/src/components/Slider.tsx +40 -38
  47. package/src/components/Switch.tsx +4 -4
  48. package/src/components/TextArea.tsx +1 -1
  49. package/src/components/Toast.tsx +99 -26
  50. package/src/components/Toolbar.tsx +114 -16
  51. package/src/components/Tooltip.tsx +18 -8
  52. package/src/components/Virtualized.tsx +193 -14
  53. package/src/components/VisuallyHidden.tsx +20 -0
  54. package/src/components/__tests__/Virtualized.math.test.ts +426 -1
  55. package/src/components/__tests__/Virtualized.test.tsx +129 -1
  56. package/src/components/ai-assistant/AIAssistantLayout.tsx +11 -6
  57. package/src/components/internal/Menu.tsx +4 -0
  58. package/src/components/listView/ListViewEditableRowTitle.tsx +1 -1
  59. package/src/components/listView/ListViewRoot.tsx +5 -1
  60. package/src/components/resizablePanels/Panel.tsx +94 -0
  61. package/src/components/resizablePanels/PanelGroup.tsx +498 -0
  62. package/src/components/resizablePanels/PanelGroupContext.tsx +14 -0
  63. package/src/components/resizablePanels/PanelResizeHandle.tsx +61 -0
  64. package/src/components/resizablePanels/index.ts +7 -0
  65. package/src/components/resizablePanels/types.ts +65 -0
  66. package/src/components/workspace/DrawerWorkspaceLayout.tsx +22 -7
  67. package/src/components/workspace/PanelWorkspaceLayout.tsx +30 -84
  68. package/src/components/workspace/WorkspaceLayout.tsx +28 -58
  69. package/src/components/workspace/types.ts +6 -4
  70. package/src/contexts/DialogContext.tsx +15 -24
  71. package/src/emojis.ts +1 -0
  72. package/src/hooks/useTriggerToggle.ts +95 -0
  73. package/src/index.css +2 -0
  74. package/src/index.tsx +1 -2
  75. package/src/theme/proseTheme.ts +2 -0
  76. package/src/utils/mergeProps.ts +57 -0
  77. package/src/utils/skinTone.ts +90 -0
  78. package/tsup.config.ts +3 -1
  79. package/src/__tests__/workspaceLayout.test.ts +0 -281
  80. package/src/components/ScrollArea2.tsx +0 -76
  81. package/src/components/internal/MenuViewport.tsx +0 -178
  82. package/src/components/internal/SelectItem.tsx +0 -138
  83. 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">&nbsp;</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 ? <ScrollArea>{content}</ScrollArea> : content}
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 : 6}px`,
65
- paddingRight: `${variant === "bare" ? endWidth : endWidth ? endWidth + 12 : 6}px`,
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