diff --git a/resources/js/components/commit-composer.tsx b/resources/js/components/commit-composer.tsx
index 4668c4e9c..275fe6756 100644
--- a/resources/js/components/commit-composer.tsx
+++ b/resources/js/components/commit-composer.tsx
@@ -19,6 +19,7 @@ import {
PreviewToggle,
useComposerPreview,
} from '@/components/composer-preview';
+import { EmojiPickerButton } from '@/components/emoji-picker';
import { GraphRail, RailAvatar } from '@/components/graph-rail';
import InputError from '@/components/input-error';
import type { UseIssueCommandReturn } from '@/components/issue-command';
@@ -38,6 +39,7 @@ import {
} from '@/components/schedule-picker';
import { StashLabel } from '@/components/stash-label';
import { Button } from '@/components/ui/button';
+import { useEmojiInsertion } from '@/hooks/use-emoji-insertion';
import { useImageAttachments } from '@/hooks/use-image-attachments';
import { useLinkCard } from '@/hooks/use-link-card';
import type { UseLinkCardReturn } from '@/hooks/use-link-card';
@@ -125,7 +127,7 @@ function CommitComposerBody({
drop: () => void;
}) {
const keys = useKeyLabels();
- const { body, isRestored } = stash;
+ const { body, isRestored, updateBody } = stash;
const { auth } = usePage().props;
const {
isScheduling,
@@ -176,6 +178,13 @@ function CommitComposerBody({
return () => observer.disconnect();
}, [isInDialog]);
+ const insertEmoji = useEmojiInsertion({
+ body,
+ updateBody,
+ textareaRef,
+ maxLength: isIssue ? maxBodyLength : maxRawBodyLength,
+ });
+
return (
+
{
const commit =
textareaRef.current?.closest('[data-commit]');
@@ -1513,6 +1522,11 @@ function ReplyComposer({
onPick={attachments.pick}
className="size-8"
/>
+
void;
+ className?: string;
+}) {
+ const textareaRef = useRef(null);
+ const [query, setQuery] = useState('');
+ const results = query.trim() === '' ? null : searchEmoji(query);
+
+ return (
+
+
+ event.preventDefault()}
+ onClick={() => {
+ const active = document.activeElement;
+
+ textareaRef.current =
+ active instanceof HTMLTextAreaElement
+ ? active
+ : null;
+ setQuery('');
+ }}
+ aria-label="Add emoji"
+ className={cn(
+ buttonVariants({ variant: 'ghost', size: 'icon' }),
+ 'text-muted-foreground',
+ className,
+ )}
+ data-test="emoji-picker-button"
+ >
+
+
+
+
+
+ {({ close }) => {
+ const pick = (emoji: string) => {
+ onPick(emoji, textareaRef.current);
+ };
+
+ const pickAndClose = (emoji: string) => {
+ const textarea = textareaRef.current;
+
+ onPick(emoji, textarea);
+ close(textarea ?? undefined);
+ };
+
+ const pickFirst = (event: KeyboardEvent) => {
+ if (event.key !== 'Enter') {
+ return;
+ }
+
+ event.preventDefault();
+
+ const first = (results ?? popularEmoji)[0];
+
+ if (first) {
+ pickAndClose(first.emoji);
+ }
+ };
+
+ return (
+ <>
+
+
+ setQuery(event.target.value)
+ }
+ onKeyDown={pickFirst}
+ placeholder="Search emoji"
+ aria-label="Search emoji"
+ autoComplete="off"
+ className="h-8 w-full rounded-md border bg-background px-2 text-base outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 md:text-sm"
+ />
+
+
+
+ {results === null ? (
+ <>
+
+
+ >
+ ) : results.length === 0 ? (
+
+ No emoji found.
+
+ ) : (
+
+ )}
+
+ >
+ );
+ }}
+
+
+ );
+}
+
+function EmojiSection({
+ title,
+ emoji,
+ onPick,
+ className,
+}: {
+ title: string;
+ emoji: PickableEmoji[];
+ onPick: (emoji: string) => void;
+ className?: string;
+}) {
+ return (
+
+ );
+}
+
+function EmojiGrid({
+ emoji,
+ onPick,
+}: {
+ emoji: PickableEmoji[];
+ onPick: (emoji: string) => void;
+}) {
+ return (
+
+ {emoji.map(({ name, emoji: character }) => (
+
+ ))}
+
+ );
+}
diff --git a/resources/js/hooks/use-emoji-insertion.ts b/resources/js/hooks/use-emoji-insertion.ts
new file mode 100644
index 000000000..2367231f5
--- /dev/null
+++ b/resources/js/hooks/use-emoji-insertion.ts
@@ -0,0 +1,50 @@
+import type { RefObject } from 'react';
+import { useLayoutEffect, useRef } from 'react';
+import { insertAtSelection } from '@/lib/text-selection';
+
+/**
+ * Inserts a picked emoji at the caret of the composer's textarea. The caret is put back once React has written the
+ * new body, since writing a long value can land after an animation frame and would leave the caret at the end. Focus
+ * is left alone so the picker can stay open for the next emoji.
+ */
+export function useEmojiInsertion({
+ body,
+ updateBody,
+ textareaRef,
+ maxLength,
+}: {
+ body: string;
+ updateBody: (body: string) => void;
+ textareaRef: RefObject;
+ maxLength: number;
+}): (emoji: string, textarea: HTMLTextAreaElement | null) => void {
+ const caret = useRef(null);
+
+ useLayoutEffect(() => {
+ const element = textareaRef.current;
+
+ if (caret.current === null || element === null) {
+ return;
+ }
+
+ element.setSelectionRange(caret.current, caret.current);
+ caret.current = null;
+ }, [body, textareaRef]);
+
+ return (emoji, textarea) => {
+ const element = textareaRef.current;
+ const inserted = insertAtSelection(
+ body,
+ textarea !== null && textarea === element ? element : null,
+ emoji,
+ maxLength,
+ );
+
+ if (inserted === null) {
+ return;
+ }
+
+ caret.current = inserted.caret;
+ updateBody(inserted.value);
+ };
+}
diff --git a/resources/js/lib/emoji-picker.ts b/resources/js/lib/emoji-picker.ts
new file mode 100644
index 000000000..50f76767b
--- /dev/null
+++ b/resources/js/lib/emoji-picker.ts
@@ -0,0 +1,67 @@
+import emojiShortcodes from '@/lib/emoji-shortcodes.json?raw';
+
+export type PickableEmoji = {
+ name: string;
+ emoji: string;
+};
+
+const popularNames = [
+ '+1',
+ 'heart',
+ 'joy',
+ 'fire',
+ 'rocket',
+ 'tada',
+ 'eyes',
+ '100',
+ 'pray',
+ 'thinking',
+ 'sweat_smile',
+ 'clap',
+ 'sparkles',
+ 'white_check_mark',
+ 'bug',
+ 'coffee',
+];
+
+const shortcodeEmoji = JSON.parse(emojiShortcodes) as Record;
+
+const seenEmoji = new Set();
+
+/**
+ * Every emoji once, under the first of its shortcodes, so aliases like :+1: and :thumbsup: share one button.
+ */
+const pickableEmoji: PickableEmoji[] = Object.entries(shortcodeEmoji).flatMap(
+ ([name, emoji]) => {
+ if (seenEmoji.has(emoji)) {
+ return [];
+ }
+
+ seenEmoji.add(emoji);
+
+ return [{ name, emoji }];
+ },
+);
+
+export const popularEmoji: PickableEmoji[] = popularNames.flatMap((name) =>
+ shortcodeEmoji[name] ? [{ name, emoji: shortcodeEmoji[name] }] : [],
+);
+
+/**
+ * Matches the search against every shortcode, aliases included, so :thumbsup finds 👍 too.
+ */
+export function searchEmoji(query: string): PickableEmoji[] {
+ const term = query.trim().replace(/^:|:$/g, '').toLowerCase();
+
+ if (term === '') {
+ return pickableEmoji;
+ }
+
+ const matching = new Set(
+ Object.entries(shortcodeEmoji)
+ .filter(([name]) => name.includes(term))
+ .map(([, emoji]) => emoji),
+ );
+
+ return pickableEmoji.filter(({ emoji }) => matching.has(emoji));
+}
diff --git a/resources/js/lib/text-selection.ts b/resources/js/lib/text-selection.ts
new file mode 100644
index 000000000..9e00401eb
--- /dev/null
+++ b/resources/js/lib/text-selection.ts
@@ -0,0 +1,25 @@
+export type TextSelection = Pick<
+ HTMLTextAreaElement,
+ 'selectionStart' | 'selectionEnd'
+>;
+
+/**
+ * Inserts the text over the selection, or at the end without one. Null when the result would be longer than
+ * `maxLength` characters, the same cap the textarea puts on typing.
+ */
+export function insertAtSelection(
+ value: string,
+ selection: TextSelection | null,
+ text: string,
+ maxLength: number,
+): { value: string; caret: number } | null {
+ const start = selection?.selectionStart ?? value.length;
+ const end = selection?.selectionEnd ?? value.length;
+ const next = value.slice(0, start) + text + value.slice(end);
+
+ if (Array.from(next).length > maxLength) {
+ return null;
+ }
+
+ return { value: next, caret: start + text.length };
+}
diff --git a/tests/Unit/TextSelection.test.ts b/tests/Unit/TextSelection.test.ts
new file mode 100644
index 000000000..1b325a716
--- /dev/null
+++ b/tests/Unit/TextSelection.test.ts
@@ -0,0 +1,42 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import { insertAtSelection } from '../../resources/js/lib/text-selection.ts';
+
+void test('inserts at the caret and moves the caret after the insertion', () => {
+ assert.deepEqual(
+ insertAtSelection(
+ 'shipped it',
+ { selectionStart: 8, selectionEnd: 8 },
+ '🚀 ',
+ 280,
+ ),
+ { value: 'shipped 🚀 it', caret: 11 },
+ );
+});
+
+void test('replaces the selected text', () => {
+ assert.deepEqual(
+ insertAtSelection(
+ 'shipped it',
+ { selectionStart: 8, selectionEnd: 10 },
+ '🎉',
+ 280,
+ ),
+ { value: 'shipped 🎉', caret: 10 },
+ );
+});
+
+void test('appends without a selection', () => {
+ assert.deepEqual(insertAtSelection('shipped', null, ' 🔥', 280), {
+ value: 'shipped 🔥',
+ caret: 10,
+ });
+});
+
+void test('refuses an insertion that would not fit, counting characters like the composer', () => {
+ assert.deepEqual(insertAtSelection('a'.repeat(279), null, '🔥', 280), {
+ value: `${'a'.repeat(279)}🔥`,
+ caret: 281,
+ });
+ assert.equal(insertAtSelection('a'.repeat(280), null, '🔥', 280), null);
+});