Skip to content

Expressions Picker

A Discord-inspired picker for Unicode emoji and caller-supplied stickers and GIFs.

Choose an emoji, sticker, or GIF from one searchable dialog. The GIFs, Stickers, and Emoji tabs sit at the top, and the Emoji tab opens by default. It follows the layout of a chat client’s emoji picker: a search field with a skin tone button, a rail of categories beside a grid of collapsible sections, and a bar at the foot that names the expression under the pointer or focus. Stickers and GIFs use the same grid with larger cells.

The picker returns the chosen item and value to your application. It ships no emoji dataset, artwork, or media service, and it does not store anything: you pass the items and categories, and you keep the list of recently used IDs.

Root class newt-expressions-picker
"use client"
import * as React from "react"

import {
  ExpressionsPicker,
  type ExpressionCategory,
  type ExpressionItem,
} from "@/registry/bases/newt/ui/expressions-picker"

/* Drawn rather than fetched, so the demo holds up offline. */
function drawn(body: string) {
  return (
    "data:image/svg+xml;utf8," +
    encodeURIComponent(
      `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">${body}</svg>`
    )
  )
}
const LAB = drawn(
  `<rect width="32" height="32" rx="16" fill="#5865f2"/><path d="M12 8h8M14 8v6l-5 9a2 2 0 0 0 2 3h10a2 2 0 0 0 2-3l-5-9V8" fill="none" stroke="#fff" stroke-width="2" stroke-linejoin="round"/>`
)
const SPARK = drawn(
  `<circle cx="16" cy="16" r="15" fill="#5865f2"/><path d="M16 7l2.6 5.6L24 14l-4 3.9.9 5.6L16 21l-4.9 2.5.9-5.6-4-3.9 5.4-1.4z" fill="#fff"/>`
)
const CHECK = drawn(
  `<circle cx="16" cy="16" r="15" fill="#23a55a"/><path d="m9 16 5 5 9-10" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>`
)
const HELLO = drawn(
  `<rect x="2" y="6" width="28" height="20" rx="6" fill="#f0b232"/><text x="16" y="20" font-family="sans-serif" font-size="8" font-weight="700" text-anchor="middle" fill="#1e1f22">HELLO</text>`
)
const THANKS = drawn(
  `<rect x="2" y="6" width="28" height="20" rx="6" fill="#23a55a"/><text x="16" y="20" font-family="sans-serif" font-size="7" font-weight="700" text-anchor="middle" fill="#fff">THANKS</text>`
)
const CONFETTI = drawn(
  `<rect width="32" height="32" rx="4" fill="#2b2d31"/><circle cx="9" cy="10" r="2" fill="#f0b232"/><circle cx="22" cy="8" r="2" fill="#5865f2"/><circle cx="15" cy="18" r="2" fill="#f23f43"/><circle cx="24" cy="22" r="2" fill="#23a55a"/>`
)

const categories: ExpressionCategory[] = [
  { id: "lab", kind: "emoji", label: "Newt Lab", iconSrc: LAB },
  { id: "smileys", kind: "emoji", label: "Smileys & Emotion", glyph: "😀" },
  { id: "people", kind: "emoji", label: "People & Body", glyph: "👋" },
  { id: "nature", kind: "emoji", label: "Animals & Nature", glyph: "🌿" },
  { id: "food", kind: "emoji", label: "Food & Drink", glyph: "🍔" },
  { id: "symbols", kind: "emoji", label: "Symbols", glyph: "❤️" },
  { id: "greetings", kind: "sticker", label: "Greetings", glyph: "🎁" },
  { id: "trending", kind: "gif", label: "Trending", glyph: "🔥" },
]

const tones = (base: string) =>
  ["🏻", "🏼", "🏽", "🏾", "🏿"].map((tone) => base + tone)

const items: ExpressionItem[] = [
  {
    id: "spark",
    kind: "emoji",
    category: "lab",
    label: "spark",
    value: ":spark:",
    src: SPARK,
  },
  {
    id: "check",
    kind: "emoji",
    category: "lab",
    label: "check",
    value: ":check:",
    src: CHECK,
  },
  {
    id: "grinning",
    kind: "emoji",
    category: "smileys",
    label: "grinning face",
    value: "😀",
    keywords: ["happy", "smile"],
  },
  {
    id: "joy",
    kind: "emoji",
    category: "smileys",
    label: "face with tears of joy",
    value: "😂",
    keywords: ["laugh"],
  },
  {
    id: "wink",
    kind: "emoji",
    category: "smileys",
    label: "winking face",
    value: "😉",
  },
  {
    id: "thinking",
    kind: "emoji",
    category: "smileys",
    label: "thinking face",
    value: "🤔",
    keywords: ["hmm"],
  },
  {
    id: "cool",
    kind: "emoji",
    category: "smileys",
    label: "smiling face with sunglasses",
    value: "😎",
  },
  {
    id: "party-face",
    kind: "emoji",
    category: "smileys",
    label: "partying face",
    value: "🥳",
    keywords: ["celebrate"],
  },
  {
    id: "wave",
    kind: "emoji",
    category: "people",
    label: "waving hand",
    value: "👋",
    keywords: ["hello"],
    skins: tones("👋"),
  },
  {
    id: "thumbs-up",
    kind: "emoji",
    category: "people",
    label: "thumbs up",
    value: "👍",
    keywords: ["yes"],
    skins: tones("👍"),
  },
  {
    id: "clap",
    kind: "emoji",
    category: "people",
    label: "clapping hands",
    value: "👏",
    skins: tones("👏"),
  },
  {
    id: "raised",
    kind: "emoji",
    category: "people",
    label: "raising hands",
    value: "🙌",
    keywords: ["celebrate"],
    skins: tones("🙌"),
  },
  {
    id: "dog",
    kind: "emoji",
    category: "nature",
    label: "dog face",
    value: "🐶",
  },
  { id: "fox", kind: "emoji", category: "nature", label: "fox", value: "🦊" },
  {
    id: "lizard",
    kind: "emoji",
    category: "nature",
    label: "lizard",
    value: "🦎",
    keywords: ["newt"],
  },
  {
    id: "moon",
    kind: "emoji",
    category: "nature",
    label: "crescent moon",
    value: "🌙",
    disabled: true,
  },
  {
    id: "coffee",
    kind: "emoji",
    category: "food",
    label: "hot beverage",
    value: "☕",
    keywords: ["coffee"],
  },
  { id: "pizza", kind: "emoji", category: "food", label: "pizza", value: "🍕" },
  {
    id: "cake",
    kind: "emoji",
    category: "food",
    label: "birthday cake",
    value: "🎂",
    keywords: ["celebrate"],
  },
  {
    id: "heart",
    kind: "emoji",
    category: "symbols",
    label: "red heart",
    value: "❤️",
    keywords: ["love"],
  },
  {
    id: "sparkles",
    kind: "emoji",
    category: "symbols",
    label: "sparkles",
    value: "✨",
  },
  {
    id: "hundred",
    kind: "emoji",
    category: "symbols",
    label: "hundred points",
    value: "💯",
  },
  {
    id: "hello-sticker",
    kind: "sticker",
    category: "greetings",
    label: "Hello sticker",
    value: "hello-sticker",
    src: HELLO,
  },
  {
    id: "thanks-sticker",
    kind: "sticker",
    category: "greetings",
    label: "Thanks sticker",
    value: "thanks-sticker",
    src: THANKS,
  },
  {
    id: "confetti-gif",
    kind: "gif",
    category: "trending",
    label: "Confetti",
    value: "confetti-gif",
    src: CONFETTI,
    keywords: ["celebrate"],
  },
  {
    id: "applause-gif",
    kind: "gif",
    category: "trending",
    label: "Applause",
    value: "applause-gif",
    keywords: ["clap"],
  },
]

export default function ExpressionsPickerDemo() {
  const [recent, setRecent] = React.useState<string[]>(["wave", "heart"])
  const [selected, setSelected] = React.useState<string | null>(null)
  return (
    <div className="grid gap-[var(--newt-space-3)]">
      <ExpressionsPicker
        items={items}
        categories={categories}
        recent={recent}
        current={selected}
        onSelect={(item, value) => {
          setSelected(value)
          setRecent((list) =>
            [item.id, ...list.filter((id) => id !== item.id)].slice(0, 18)
          )
        }}
      />
      <p role="status" className="m-0 text-newt-text-secondary">
        {selected
          ? `Selected: ${selected}`
          : "Choose an emoji, sticker, or GIF."}
      </p>
    </div>
  )
}
<script setup lang="ts">
import { shallowRef } from "vue"
import {
  ExpressionsPicker,
  type ExpressionCategory,
  type ExpressionItem,
} from "~~/registry/bases/newt/ui/expressions-picker"

/* Drawn rather than fetched, so the demo holds up offline. */
function drawn(body: string) {
  return (
    "data:image/svg+xml;utf8," +
    encodeURIComponent(
      `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">${body}</svg>`
    )
  )
}
const LAB = drawn(
  `<rect width="32" height="32" rx="16" fill="#5865f2"/><path d="M12 8h8M14 8v6l-5 9a2 2 0 0 0 2 3h10a2 2 0 0 0 2-3l-5-9V8" fill="none" stroke="#fff" stroke-width="2" stroke-linejoin="round"/>`
)
const SPARK = drawn(
  `<circle cx="16" cy="16" r="15" fill="#5865f2"/><path d="M16 7l2.6 5.6L24 14l-4 3.9.9 5.6L16 21l-4.9 2.5.9-5.6-4-3.9 5.4-1.4z" fill="#fff"/>`
)
const CHECK = drawn(
  `<circle cx="16" cy="16" r="15" fill="#23a55a"/><path d="m9 16 5 5 9-10" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>`
)
const HELLO = drawn(
  `<rect x="2" y="6" width="28" height="20" rx="6" fill="#f0b232"/><text x="16" y="20" font-family="sans-serif" font-size="8" font-weight="700" text-anchor="middle" fill="#1e1f22">HELLO</text>`
)
const THANKS = drawn(
  `<rect x="2" y="6" width="28" height="20" rx="6" fill="#23a55a"/><text x="16" y="20" font-family="sans-serif" font-size="7" font-weight="700" text-anchor="middle" fill="#fff">THANKS</text>`
)
const CONFETTI = drawn(
  `<rect width="32" height="32" rx="4" fill="#2b2d31"/><circle cx="9" cy="10" r="2" fill="#f0b232"/><circle cx="22" cy="8" r="2" fill="#5865f2"/><circle cx="15" cy="18" r="2" fill="#f23f43"/><circle cx="24" cy="22" r="2" fill="#23a55a"/>`
)

const categories: ExpressionCategory[] = [
  { id: "lab", kind: "emoji", label: "Newt Lab", iconSrc: LAB },
  { id: "smileys", kind: "emoji", label: "Smileys & Emotion", glyph: "😀" },
  { id: "people", kind: "emoji", label: "People & Body", glyph: "👋" },
  { id: "nature", kind: "emoji", label: "Animals & Nature", glyph: "🌿" },
  { id: "food", kind: "emoji", label: "Food & Drink", glyph: "🍔" },
  { id: "symbols", kind: "emoji", label: "Symbols", glyph: "❤️" },
  { id: "greetings", kind: "sticker", label: "Greetings", glyph: "🎁" },
  { id: "trending", kind: "gif", label: "Trending", glyph: "🔥" },
]

const tones = (base: string) =>
  ["🏻", "🏼", "🏽", "🏾", "🏿"].map((tone) => base + tone)

const items: ExpressionItem[] = [
  {
    id: "spark",
    kind: "emoji",
    category: "lab",
    label: "spark",
    value: ":spark:",
    src: SPARK,
  },
  {
    id: "check",
    kind: "emoji",
    category: "lab",
    label: "check",
    value: ":check:",
    src: CHECK,
  },
  {
    id: "grinning",
    kind: "emoji",
    category: "smileys",
    label: "grinning face",
    value: "😀",
    keywords: ["happy", "smile"],
  },
  {
    id: "joy",
    kind: "emoji",
    category: "smileys",
    label: "face with tears of joy",
    value: "😂",
    keywords: ["laugh"],
  },
  {
    id: "wink",
    kind: "emoji",
    category: "smileys",
    label: "winking face",
    value: "😉",
  },
  {
    id: "thinking",
    kind: "emoji",
    category: "smileys",
    label: "thinking face",
    value: "🤔",
    keywords: ["hmm"],
  },
  {
    id: "cool",
    kind: "emoji",
    category: "smileys",
    label: "smiling face with sunglasses",
    value: "😎",
  },
  {
    id: "party-face",
    kind: "emoji",
    category: "smileys",
    label: "partying face",
    value: "🥳",
    keywords: ["celebrate"],
  },
  {
    id: "wave",
    kind: "emoji",
    category: "people",
    label: "waving hand",
    value: "👋",
    keywords: ["hello"],
    skins: tones("👋"),
  },
  {
    id: "thumbs-up",
    kind: "emoji",
    category: "people",
    label: "thumbs up",
    value: "👍",
    keywords: ["yes"],
    skins: tones("👍"),
  },
  {
    id: "clap",
    kind: "emoji",
    category: "people",
    label: "clapping hands",
    value: "👏",
    skins: tones("👏"),
  },
  {
    id: "raised",
    kind: "emoji",
    category: "people",
    label: "raising hands",
    value: "🙌",
    keywords: ["celebrate"],
    skins: tones("🙌"),
  },
  {
    id: "dog",
    kind: "emoji",
    category: "nature",
    label: "dog face",
    value: "🐶",
  },
  { id: "fox", kind: "emoji", category: "nature", label: "fox", value: "🦊" },
  {
    id: "lizard",
    kind: "emoji",
    category: "nature",
    label: "lizard",
    value: "🦎",
    keywords: ["newt"],
  },
  {
    id: "moon",
    kind: "emoji",
    category: "nature",
    label: "crescent moon",
    value: "🌙",
    disabled: true,
  },
  {
    id: "coffee",
    kind: "emoji",
    category: "food",
    label: "hot beverage",
    value: "☕",
    keywords: ["coffee"],
  },
  { id: "pizza", kind: "emoji", category: "food", label: "pizza", value: "🍕" },
  {
    id: "cake",
    kind: "emoji",
    category: "food",
    label: "birthday cake",
    value: "🎂",
    keywords: ["celebrate"],
  },
  {
    id: "heart",
    kind: "emoji",
    category: "symbols",
    label: "red heart",
    value: "❤️",
    keywords: ["love"],
  },
  {
    id: "sparkles",
    kind: "emoji",
    category: "symbols",
    label: "sparkles",
    value: "✨",
  },
  {
    id: "hundred",
    kind: "emoji",
    category: "symbols",
    label: "hundred points",
    value: "💯",
  },
  {
    id: "hello-sticker",
    kind: "sticker",
    category: "greetings",
    label: "Hello sticker",
    value: "hello-sticker",
    src: HELLO,
  },
  {
    id: "thanks-sticker",
    kind: "sticker",
    category: "greetings",
    label: "Thanks sticker",
    value: "thanks-sticker",
    src: THANKS,
  },
  {
    id: "confetti-gif",
    kind: "gif",
    category: "trending",
    label: "Confetti",
    value: "confetti-gif",
    src: CONFETTI,
    keywords: ["celebrate"],
  },
  {
    id: "applause-gif",
    kind: "gif",
    category: "trending",
    label: "Applause",
    value: "applause-gif",
    keywords: ["clap"],
  },
]

const recent = shallowRef<string[]>(["wave", "heart"])
const selected = shallowRef<string | null>(null)

function onSelect(item: ExpressionItem, value: string) {
  selected.value = value
  recent.value = [
    item.id,
    ...recent.value.filter((id) => id !== item.id),
  ].slice(0, 18)
}
</script>

<template>
  <div class="grid gap-[var(--newt-space-3)]">
    <ExpressionsPicker
      :items="items"
      :categories="categories"
      :recent="recent"
      :current="selected"
      @select="onSelect"
    />
    <p role="status" class="m-0 text-newt-text-secondary">
      {{
        selected ? `Selected: ${selected}` : "Choose an emoji, sticker, or GIF."
      }}
    </p>
  </div>
</template>

The framework switch above selects the React or Vue demo. Search for “celebrate”, pick a skin tone, collapse a section, and select an item: it moves to the top of “Recently used” and stays marked as pressed. The crescent moon demonstrates an unavailable entry. “Newt Lab” shows how a server’s own emoji appear with an image.

Installation

pnpm dlx newtui@latest add expressions-picker
npx newtui@latest add expressions-picker
yarn dlx newtui@latest add expressions-picker
bunx newtui@latest add expressions-picker

Install the dependencies

pnpm dlx --yes class-variance-authority
npx --yes class-variance-authority
yarn dlx --yes class-variance-authority
bunx --yes class-variance-authority

Copy the source into your project

"use client"

import { cva } from "class-variance-authority"
import * as React from "react"

import { cn } from "@/lib/utils"

export type ExpressionKind = "emoji" | "sticker" | "gif"

/** A section of one tab: a Unicode emoji group, a server's own emoji, or a sticker pack. */
export interface ExpressionCategory {
  /** Unique across all categories. */
  id: string
  kind: ExpressionKind
  label: string
  /** A Unicode character for the category rail. */
  glyph?: string
  /** A static image for the category rail, such as a server icon. Wins over `glyph`. */
  iconSrc?: string
}

export interface ExpressionItem {
  /** Unique across all items. */
  id: string
  kind: ExpressionKind
  /** The `id` of the category this item belongs to. */
  category: string
  /** Accessible name, searched and shown in the preview bar. */
  label: string
  /** What a selection returns: Unicode text, or an application token for media. */
  value: string
  keywords?: readonly string[]
  /** The emoji in the five skin tones, light to dark. Omit when it has none. */
  skins?: readonly string[]
  /** A static image: a server emoji, a sticker, or a GIF poster. */
  src?: string
  disabled?: boolean
}

export type SkinTone = 0 | 1 | 2 | 3 | 4 | 5

export interface ExpressionsPickerProps extends Omit<
  React.HTMLAttributes<HTMLDivElement>,
  "onSelect"
> {
  items?: readonly ExpressionItem[]
  categories?: readonly ExpressionCategory[]
  /** Item IDs used last, most recent first. The application stores and updates them. */
  recent?: readonly string[]
  /** The value chosen now, marked as pressed in the grid. */
  current?: string | null
  label?: string
  disabled?: boolean
  /** Receives the item and its value in the chosen skin tone. */
  onSelect?: (item: ExpressionItem, value: string) => void
}

const kinds: { kind: ExpressionKind; label: string }[] = [
  { kind: "gif", label: "GIFs" },
  { kind: "sticker", label: "Stickers" },
  { kind: "emoji", label: "Emoji" },
]

const skinTones: { tone: SkinTone; label: string; sample: string }[] = [
  { tone: 0, label: "Default skin tone", sample: "👋" },
  { tone: 1, label: "Light skin tone", sample: "👋🏻" },
  { tone: 2, label: "Medium-light skin tone", sample: "👋🏼" },
  { tone: 3, label: "Medium skin tone", sample: "👋🏽" },
  { tone: 4, label: "Medium-dark skin tone", sample: "👋🏾" },
  { tone: 5, label: "Dark skin tone", sample: "👋🏿" },
]

/** The value of an item in a skin tone, or its own value when it has none. */
export function expressionValue(item: ExpressionItem, tone: SkinTone): string {
  return (tone === 0 ? undefined : item.skins?.[tone - 1]) ?? item.value
}

/** `:grinning_face:` for an emoji, the label for media. */
export function expressionShortcode(item: ExpressionItem): string {
  if (item.kind !== "emoji") return item.label
  const name = item.label
    .toLowerCase()
    .replaceAll(/[^\p{L}\p{N}]+/gu, "_")
    .replaceAll(/^_+|_+$/gu, "")
  return `:${name}:`
}

/** Every word of the query must appear in the label or the keywords. */
export function matchesExpression(item: ExpressionItem, query: string) {
  const text = [item.label, ...(item.keywords ?? [])].join(" ").toLowerCase()
  return query
    .toLowerCase()
    .split(/[\s_:]+/u)
    .filter(Boolean)
    .every((word) => text.includes(word))
}

interface Section {
  key: string
  label: string
  glyph?: string
  iconSrc?: string
  items: ExpressionItem[]
  collapsible: boolean
}

function buildSections(
  items: readonly ExpressionItem[],
  categories: readonly ExpressionCategory[],
  recent: readonly string[],
  kind: ExpressionKind,
  query: string
): Section[] {
  const ofKind = items.filter((item) => item.kind === kind)
  if (query.trim()) {
    const found = ofKind.filter((item) => matchesExpression(item, query))
    return found.length
      ? [
          {
            key: "results",
            label: "Search results",
            items: found,
            collapsible: false,
          },
        ]
      : []
  }
  const byId = new Map(ofKind.map((item) => [item.id, item]))
  const sections: Section[] = []
  const used = recent.flatMap((id) => {
    const item = byId.get(id)
    return item ? [item] : []
  })
  if (used.length)
    sections.push({
      key: "recent",
      label: "Recently used",
      glyph: "🕘",
      items: used,
      collapsible: true,
    })
  for (const category of categories) {
    if (category.kind !== kind) continue
    const list = ofKind.filter((item) => item.category === category.id)
    if (list.length)
      sections.push({
        key: `category-${category.id}`,
        label: category.label,
        glyph: category.glyph,
        iconSrc: category.iconSrc,
        items: list,
        collapsible: true,
      })
  }
  return sections
}

/** The cell an arrow key leads to, by where the cells sit on screen. */
function neighbour(
  cells: HTMLElement[],
  index: number,
  key: string,
  rtl: boolean
): HTMLElement | undefined {
  if (key === (rtl ? "ArrowLeft" : "ArrowRight")) return cells[index + 1]
  if (key === (rtl ? "ArrowRight" : "ArrowLeft")) return cells[index - 1]
  if (key === "Home") return cells[0]
  if (key === "End") return cells.at(-1)
  if (key !== "ArrowDown" && key !== "ArrowUp") return undefined
  const from = cells[index]?.getBoundingClientRect()
  if (!from) return undefined
  const step = key === "ArrowDown" ? 1 : -1
  let rowTop: number | null = null
  let best: { cell: HTMLElement; distance: number } | undefined
  for (let at = index + step; at >= 0 && at < cells.length; at += step) {
    const cell = cells[at]
    if (!cell) break
    const rect = cell.getBoundingClientRect()
    if (Math.abs(rect.top - from.top) < 4) continue
    rowTop ??= rect.top
    if (Math.abs(rect.top - rowTop) >= 4) break
    const distance = Math.abs(rect.left - from.left)
    if (!best || distance < best.distance) best = { cell, distance }
  }
  return best?.cell
}

const focusRing =
  "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-newt-text-link"

/** Tabs and the small controls of the panel. */
export const expressionsPickerVariants = cva(
  `inline-flex min-h-[calc(var(--newt-expressions-target-size)-var(--newt-space-2))] min-w-[calc(var(--newt-expressions-target-size)-var(--newt-space-2))] cursor-pointer items-center justify-center gap-[var(--newt-space-1)] rounded-md border-0 px-[var(--newt-space-3)] font-sans text-[length:var(--newt-font-size-sm)] font-semibold leading-[var(--newt-line-height-normal)] enabled:hover:bg-newt-bg-hover enabled:hover:text-newt-text-primary enabled:active:bg-newt-bg-active disabled:cursor-not-allowed disabled:text-newt-text-muted ${focusRing}`,
  {
    variants: {
      selected: {
        true: "bg-newt-bg-active text-newt-text-primary",
        false: "bg-transparent text-newt-text-secondary",
      },
    },
    defaultVariants: { selected: false },
  }
)

const triggerClass = `min-h-[var(--newt-expressions-target-size)] cursor-pointer rounded-sm border border-newt-border bg-newt-bg-elevated px-[var(--newt-space-3)] py-[var(--newt-space-2)] font-sans text-[length:var(--newt-font-size-sm)] leading-[var(--newt-line-height-normal)] text-newt-text-primary enabled:hover:bg-newt-bg-hover enabled:active:bg-newt-bg-active disabled:cursor-not-allowed disabled:text-newt-text-muted ${focusRing}`

function trapFocus(event: React.KeyboardEvent<HTMLDialogElement>) {
  if (event.key !== "Tab") return
  const controls = [
    ...event.currentTarget.querySelectorAll<HTMLElement>(
      "button:not(:disabled), input:not(:disabled)"
    ),
  ].filter(
    (element) => element.tabIndex >= 0 && element.getClientRects().length > 0
  )
  const first = controls[0]
  const last = controls[controls.length - 1]
  if (event.shiftKey && document.activeElement === first) {
    event.preventDefault()
    last?.focus()
  } else if (!event.shiftKey && document.activeElement === last) {
    event.preventDefault()
    first?.focus()
  }
}

function ExpressionArt({
  item,
  value,
  large,
}: {
  item: ExpressionItem
  value: string
  large?: boolean
}) {
  if (item.src)
    return (
      <img
        src={item.src}
        alt=""
        loading="lazy"
        className={cn(
          "object-contain",
          large
            ? "size-[calc(var(--newt-expressions-preview-size)-var(--newt-space-3))]"
            : "size-[calc(var(--newt-expressions-target-size)-var(--newt-space-2))]"
        )}
      />
    )
  if (item.kind === "emoji")
    return (
      <span
        aria-hidden="true"
        className="text-[length:var(--newt-font-size-expression)] leading-none"
      >
        {value}
      </span>
    )
  return (
    <span
      aria-hidden="true"
      className="px-[var(--newt-space-1)] text-center text-[length:var(--newt-font-size-xs)] text-newt-text-secondary"
    >
      {item.label}
    </span>
  )
}

function SectionIcon({ section }: { section: Section }) {
  if (section.iconSrc)
    return (
      <img
        src={section.iconSrc}
        alt=""
        className="size-[calc(var(--newt-space-3)+var(--newt-space-1))] shrink-0 rounded-full"
      />
    )
  if (!section.glyph) return null
  return (
    <span aria-hidden="true" className="shrink-0 leading-none">
      {section.glyph}
    </span>
  )
}

const ExpressionsPicker = React.forwardRef<
  HTMLDivElement,
  ExpressionsPickerProps
>(
  (
    {
      items = [],
      categories = [],
      recent = [],
      current = null,
      label = "Choose an expression",
      disabled = false,
      onSelect,
      className,
      ...props
    },
    ref
  ) => {
    const id = React.useId()
    const dialog = React.useRef<HTMLDialogElement>(null)
    const trigger = React.useRef<HTMLButtonElement>(null)
    const search = React.useRef<HTMLInputElement>(null)
    const scroller = React.useRef<HTMLDivElement>(null)
    const toneButton = React.useRef<HTMLButtonElement>(null)
    const toneRoot = React.useRef<HTMLDivElement>(null)
    const [open, setOpen] = React.useState(false)
    const [kind, setKind] = React.useState<ExpressionKind>("emoji")
    const [query, setQuery] = React.useState("")
    const [tone, setTone] = React.useState<SkinTone>(0)
    const [showTones, setShowTones] = React.useState(false)
    const [collapsed, setCollapsed] = React.useState<string[]>([])
    const [scrolledTo, setScrolledTo] = React.useState<string | null>(null)
    const [pendingScroll, setPendingScroll] = React.useState<string | null>(
      null
    )
    const [activeKey, setActiveKey] = React.useState<string | null>(null)
    const [hovered, setHovered] = React.useState<{
      item: ExpressionItem
      where: string
    } | null>(null)

    const searching = query.trim().length > 0
    const sections = buildSections(items, categories, recent, kind, query)
    const isOpen = (key: string) => !collapsed.includes(key)
    const activeSection = scrolledTo ?? sections[0]?.key
    const cellKeys = sections
      .filter((section) => isOpen(section.key))
      .flatMap((section) =>
        section.items
          .filter((item) => !item.disabled)
          .map((item) => `${section.key}|${item.id}`)
      )
    const tabKey =
      activeKey && cellKeys.includes(activeKey) ? activeKey : cellKeys[0]
    const resultCount = sections.reduce(
      (count, section) => count + section.items.length,
      0
    )

    const currentItem = React.useMemo(() => {
      if (!current) return null
      const item = items.find(
        (entry) =>
          entry.kind === kind &&
          (entry.value === current || entry.skins?.includes(current))
      )
      if (!item) return null
      return {
        item,
        where:
          categories.find((category) => category.id === item.category)?.label ??
          "",
      }
    }, [categories, current, items, kind])
    const preview = hovered ?? currentItem

    function toggleSection(key: string) {
      setCollapsed((list) =>
        list.includes(key)
          ? list.filter((entry) => entry !== key)
          : [...list, key]
      )
    }
    function goTo(key: string) {
      setCollapsed((list) => list.filter((entry) => entry !== key))
      setScrolledTo(key)
      setPendingScroll(key)
    }
    React.useLayoutEffect(() => {
      if (!pendingScroll || !scroller.current) return
      const section = scroller.current.querySelector<HTMLElement>(
        `[data-section="${pendingScroll}"]`
      )
      if (section) scroller.current.scrollTop = section.offsetTop
      setPendingScroll(null)
    }, [pendingScroll])

    function onScroll() {
      const element = scroller.current
      if (!element) return
      let found: string | null = null
      for (const section of element.querySelectorAll<HTMLElement>(
        "[data-section]"
      )) {
        if (section.offsetTop > element.scrollTop + 8) break
        found = section.dataset.section ?? null
      }
      if (found) setScrolledTo(found)
    }

    function show() {
      if (disabled) return
      setQuery("")
      setHovered(null)
      setShowTones(false)
      dialog.current?.showModal()
      setOpen(true)
      search.current?.focus()
    }
    function close() {
      dialog.current?.close()
    }
    React.useEffect(() => {
      if (disabled) dialog.current?.close()
    }, [disabled])

    function changeKind(next: ExpressionKind) {
      setKind(next)
      setScrolledTo(null)
      setHovered(null)
      setShowTones(false)
      if (scroller.current) scroller.current.scrollTop = 0
    }

    function tabKeyDown(
      event: React.KeyboardEvent<HTMLButtonElement>,
      index: number
    ) {
      const rtl = getComputedStyle(event.currentTarget).direction === "rtl"
      const forward = rtl ? "ArrowLeft" : "ArrowRight"
      const back = rtl ? "ArrowRight" : "ArrowLeft"
      const next =
        event.key === "Home"
          ? 0
          : event.key === "End"
            ? kinds.length - 1
            : event.key === forward
              ? (index + 1) % kinds.length
              : event.key === back
                ? (index + kinds.length - 1) % kinds.length
                : -1
      const nextTab = kinds[next]
      if (!nextTab) return
      event.preventDefault()
      changeKind(nextTab.kind)
      dialog.current
        ?.querySelectorAll<HTMLButtonElement>('[role="tab"]')
        .item(next)
        ?.focus()
    }

    function gridKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
      const target = event.target
      if (!(target instanceof HTMLElement) || !target.matches("[data-cell]"))
        return
      const cells = [
        ...(scroller.current?.querySelectorAll<HTMLElement>(
          "[data-cell]:not(:disabled)"
        ) ?? []),
      ]
      const rtl = getComputedStyle(target).direction === "rtl"
      const next = neighbour(cells, cells.indexOf(target), event.key, rtl)
      if (!next) return
      event.preventDefault()
      next.focus()
      next.scrollIntoView({ block: "nearest" })
    }

    function focusFirstCell(event: React.KeyboardEvent<HTMLInputElement>) {
      if (event.key !== "ArrowDown") return
      event.preventDefault()
      scroller.current
        ?.querySelector<HTMLElement>('[data-cell][tabindex="0"]')
        ?.focus()
    }

    function onDialogKeyDown(event: React.KeyboardEvent<HTMLDialogElement>) {
      if (event.key === "Escape" && showTones) {
        event.preventDefault()
        setShowTones(false)
        toneButton.current?.focus()
        return
      }
      trapFocus(event)
    }

    function pickTone(next: SkinTone) {
      setTone(next)
      setShowTones(false)
      toneButton.current?.focus()
    }
    React.useEffect(() => {
      if (!showTones) return
      toneRoot.current
        ?.querySelector<HTMLElement>('[aria-pressed="true"]')
        ?.focus()
      function outside(event: PointerEvent) {
        const target = event.target
        if (!(target instanceof Node) || !toneRoot.current?.contains(target))
          setShowTones(false)
      }
      document.addEventListener("pointerdown", outside)
      return () => document.removeEventListener("pointerdown", outside)
    }, [showTones])

    function select(item: ExpressionItem) {
      if (disabled || item.disabled) return
      close()
      onSelect?.(item, expressionValue(item, tone))
    }

    const toneSample = skinTones[tone]

    return (
      <div ref={ref} className={cn("inline-block", className)} {...props}>
        <button
          ref={trigger}
          type="button"
          disabled={disabled}
          aria-haspopup="dialog"
          aria-expanded={open}
          aria-controls={id}
          onClick={show}
          className={triggerClass}
        >
          {label}
        </button>
        <dialog
          ref={dialog}
          id={id}
          aria-label={label}
          onKeyDown={onDialogKeyDown}
          onClick={(event) => {
            // The panel fills the dialog, so only the backdrop targets it.
            if (event.target === event.currentTarget) close()
          }}
          onClose={() => {
            setOpen(false)
            setShowTones(false)
            trigger.current?.focus()
          }}
          className="m-auto box-border h-[min(var(--newt-expressions-height),calc(100dvh-2*var(--newt-space-3)))] w-[min(var(--newt-expressions-width),calc(100%-2*var(--newt-space-3)))] max-w-none overflow-hidden rounded-lg border border-newt-border bg-newt-bg-surface p-0 font-sans text-[length:var(--newt-font-size-sm)] leading-[var(--newt-line-height-normal)] text-newt-text-primary shadow-elevation-high backdrop:bg-[color-mix(in_srgb,var(--newt-bg-base)_80%,transparent)] open:flex open:flex-col"
        >
          <div className="flex shrink-0 items-center gap-[var(--newt-space-2)] px-[var(--newt-space-3)] pt-[var(--newt-space-3)]">
            <div
              role="tablist"
              aria-label="Expression type"
              className="flex items-center gap-[var(--newt-space-2)]"
            >
              {kinds.map((tab, index) => (
                <button
                  key={tab.kind}
                  id={`${id}-${tab.kind}`}
                  role="tab"
                  type="button"
                  aria-selected={kind === tab.kind}
                  aria-controls={`${id}-panel`}
                  tabIndex={kind === tab.kind ? 0 : -1}
                  onClick={() => changeKind(tab.kind)}
                  onKeyDown={(event) => tabKeyDown(event, index)}
                  className={expressionsPickerVariants({
                    selected: kind === tab.kind,
                  })}
                >
                  {tab.label}
                </button>
              ))}
            </div>
          </div>

          <div className="flex shrink-0 items-center gap-[var(--newt-space-2)] border-b border-newt-border p-[var(--newt-space-3)]">
            <div className="relative min-w-0 flex-1">
              <input
                ref={search}
                type="text"
                autoComplete="off"
                spellCheck={false}
                value={query}
                aria-label="Search expressions"
                placeholder="Search by name or keyword"
                onChange={(event) => {
                  setQuery(event.target.value)
                  setHovered(null)
                }}
                onKeyDown={focusFirstCell}
                className={`box-border h-[calc(var(--newt-expressions-target-size)-var(--newt-space-2))] w-full rounded-md border-0 bg-newt-bg-input pe-[calc(var(--newt-expressions-target-size)-var(--newt-space-2))] ps-[calc(var(--newt-space-3)*3)] font-sans text-[length:var(--newt-font-size-sm)] text-newt-text-primary placeholder:text-newt-text-muted ${focusRing}`}
              />
              <svg
                aria-hidden="true"
                viewBox="0 0 16 16"
                className="pointer-events-none absolute start-[var(--newt-space-3)] top-1/2 size-[calc(var(--newt-space-3)+var(--newt-space-1))] -translate-y-1/2 fill-none stroke-newt-text-secondary stroke-2"
              >
                <circle cx="7" cy="7" r="5" />
                <path d="m11 11 4 4" strokeLinecap="round" />
              </svg>
              {query ? (
                <button
                  type="button"
                  aria-label="Clear search"
                  onClick={() => {
                    setQuery("")
                    search.current?.focus()
                  }}
                  className={cn(
                    expressionsPickerVariants(),
                    "absolute end-[var(--newt-space-1)] top-1/2 min-h-0 min-w-0 -translate-y-1/2 size-[calc(var(--newt-expressions-target-size)-var(--newt-space-3)-var(--newt-space-2))] p-0"
                  )}
                >
                  <span aria-hidden="true">×</span>
                </button>
              ) : null}
            </div>
            {kind === "emoji" ? (
              <div ref={toneRoot} className="relative shrink-0">
                <button
                  ref={toneButton}
                  type="button"
                  aria-label={`Skin tone: ${toneSample?.label}`}
                  aria-expanded={showTones}
                  onClick={() => setShowTones((value) => !value)}
                  className={cn(
                    expressionsPickerVariants({ selected: showTones }),
                    "p-0 no-underline"
                  )}
                >
                  <span
                    aria-hidden="true"
                    className="text-[length:var(--newt-font-size-expression)] leading-none"
                  >
                    {toneSample?.sample}
                  </span>
                </button>
                {showTones ? (
                  <div
                    role="group"
                    aria-label="Skin tone"
                    className="absolute -end-[var(--newt-space-1)] -top-[var(--newt-space-1)] z-20 flex flex-col gap-[var(--newt-space-1)] rounded-md border border-newt-border bg-newt-bg-floating p-[var(--newt-space-1)] shadow-elevation-high"
                  >
                    {skinTones.map((option) => (
                      <button
                        key={option.tone}
                        type="button"
                        aria-label={option.label}
                        title={option.label}
                        aria-pressed={tone === option.tone}
                        onClick={() => pickTone(option.tone)}
                        className={cn(
                          expressionsPickerVariants(),
                          "p-0 aria-pressed:bg-newt-bg-active aria-pressed:shadow-[inset_0_0_0_2px_var(--newt-brand)]"
                        )}
                      >
                        <span
                          aria-hidden="true"
                          className="text-[length:var(--newt-font-size-expression)] leading-none"
                        >
                          {option.sample}
                        </span>
                      </button>
                    ))}
                  </div>
                ) : null}
              </div>
            ) : null}
          </div>

          <div
            id={`${id}-panel`}
            role="tabpanel"
            aria-labelledby={`${id}-${kind}`}
            className="flex min-h-0 flex-1"
          >
            {!searching && sections.length > 1 ? (
              <nav
                aria-label="Categories"
                className="flex w-[calc(var(--newt-expressions-target-size)+var(--newt-space-2))] shrink-0 flex-col items-center gap-[var(--newt-space-1)] overflow-y-auto overflow-x-hidden border-e border-newt-border p-[var(--newt-space-2)] ![scrollbar-width:none]"
              >
                {sections.map((section) => (
                  <button
                    key={section.key}
                    type="button"
                    aria-label={section.label}
                    title={section.label}
                    aria-current={
                      activeSection === section.key ? "true" : undefined
                    }
                    onClick={() => goTo(section.key)}
                    className={cn(
                      expressionsPickerVariants(),
                      "relative shrink-0 p-0",
                      activeSection === section.key &&
                        "text-newt-text-primary before:absolute before:-start-[var(--newt-space-2)] before:top-1/2 before:h-[calc(var(--newt-space-3)+var(--newt-space-2))] before:w-[var(--newt-space-1)] before:-translate-y-1/2 before:rounded-e-full before:bg-newt-text-primary before:content-['']"
                    )}
                  >
                    {section.iconSrc ? (
                      <img
                        src={section.iconSrc}
                        alt=""
                        className="size-[calc(var(--newt-expressions-target-size)-var(--newt-space-3)-var(--newt-space-1))] rounded-full"
                      />
                    ) : (
                      <span
                        aria-hidden="true"
                        className="text-[length:calc(var(--newt-font-size-expression)*0.625)] leading-none"
                      >
                        {section.glyph ?? section.label.slice(0, 1)}
                      </span>
                    )}
                  </button>
                ))}
              </nav>
            ) : null}

            <div
              ref={scroller}
              onScroll={onScroll}
              onKeyDown={gridKeyDown}
              onMouseLeave={() => setHovered(null)}
              className="relative min-w-0 flex-1 overflow-y-auto px-[var(--newt-space-2)] pb-[var(--newt-space-2)]"
            >
              {sections.map((section) => (
                <section
                  key={section.key}
                  data-section={section.key}
                  aria-labelledby={`${id}-${section.key}-title`}
                >
                  <h3
                    id={`${id}-${section.key}-title`}
                    className="sticky top-0 z-10 m-0 bg-newt-bg-surface pb-[var(--newt-space-1)] pt-[var(--newt-space-2)] text-[length:var(--newt-font-size-sm)] font-semibold text-newt-text-secondary"
                  >
                    {section.collapsible ? (
                      <button
                        type="button"
                        aria-expanded={isOpen(section.key)}
                        aria-controls={`${id}-${section.key}`}
                        onClick={() => toggleSection(section.key)}
                        className={`inline-flex max-w-full cursor-pointer items-center gap-[var(--newt-space-2)] rounded-sm border-0 bg-transparent px-[var(--newt-space-1)] font-[inherit] text-[inherit] hover:text-newt-text-primary ${focusRing}`}
                      >
                        <SectionIcon section={section} />
                        <span className="min-w-0 truncate">
                          {section.label}
                        </span>
                        <svg
                          aria-hidden="true"
                          viewBox="0 0 16 16"
                          className={cn(
                            "size-[var(--newt-space-3)] shrink-0 fill-none stroke-current stroke-2",
                            !isOpen(section.key) &&
                              "[transform:rotate(calc(-90deg*var(--newt-dir)))]"
                          )}
                        >
                          <path
                            d="m4 6 4 4 4-4"
                            strokeLinecap="round"
                            strokeLinejoin="round"
                          />
                        </svg>
                      </button>
                    ) : (
                      <span className="inline-flex items-center gap-[var(--newt-space-2)] px-[var(--newt-space-1)]">
                        {section.label}
                      </span>
                    )}
                  </h3>
                  {isOpen(section.key) ? (
                    <div
                      id={`${id}-${section.key}`}
                      className={cn(
                        "grid pt-[var(--newt-space-1)]",
                        kind === "emoji"
                          ? "grid-cols-[repeat(auto-fill,var(--newt-expressions-target-size))]"
                          : "grid-cols-[repeat(auto-fill,minmax(var(--newt-expressions-preview-size),1fr))] gap-[var(--newt-space-1)]"
                      )}
                    >
                      {section.items.map((item) => {
                        const key = `${section.key}|${item.id}`
                        const value = expressionValue(item, tone)
                        const where =
                          categories.find(
                            (category) => category.id === item.category
                          )?.label ?? ""
                        return (
                          <button
                            key={key}
                            type="button"
                            data-cell
                            disabled={disabled || item.disabled}
                            tabIndex={key === tabKey ? 0 : -1}
                            aria-label={
                              item.disabled
                                ? `${item.label}, unavailable`
                                : item.label
                            }
                            aria-pressed={
                              current != null &&
                              (item.value === current ||
                                item.skins?.includes(current) === true)
                            }
                            onClick={() => select(item)}
                            onFocus={() => {
                              setActiveKey(key)
                              setHovered({ item, where })
                            }}
                            onMouseEnter={() => setHovered({ item, where })}
                            className={cn(
                              "flex cursor-pointer items-center justify-center rounded-md border border-transparent bg-transparent p-0 enabled:hover:bg-newt-bg-hover aria-pressed:bg-newt-bg-active aria-pressed:shadow-[inset_0_0_0_2px_var(--newt-brand)] disabled:cursor-not-allowed disabled:border-dashed disabled:border-newt-border disabled:opacity-50",
                              kind === "emoji"
                                ? "size-[var(--newt-expressions-target-size)]"
                                : "aspect-square w-full",
                              focusRing
                            )}
                          >
                            <ExpressionArt
                              item={item}
                              value={value}
                              large={kind !== "emoji"}
                            />
                          </button>
                        )
                      })}
                    </div>
                  ) : null}
                </section>
              ))}
              {sections.length === 0 ? (
                <div
                  role="status"
                  className="flex flex-col items-center px-[var(--newt-space-3)] py-[calc(var(--newt-space-3)*3)] text-center text-newt-text-secondary"
                >
                  <p className="m-0 font-semibold text-newt-text-primary">
                    {searching
                      ? "No expressions match your search."
                      : "Nothing here yet."}
                  </p>
                  <p className="m-0">
                    {searching
                      ? "Try another word or another tab."
                      : "Try another tab."}
                  </p>
                </div>
              ) : searching ? (
                <p role="status" className="sr-only">
                  {resultCount === 1 ? "1 result" : `${resultCount} results`}
                </p>
              ) : null}
            </div>
          </div>

          <div className="flex h-[calc(var(--newt-expressions-target-size)+var(--newt-space-3))] shrink-0 items-center gap-[var(--newt-space-3)] border-t border-newt-border bg-newt-bg-floating px-[var(--newt-space-3)]">
            {preview ? (
              <>
                <span
                  aria-hidden="true"
                  className="flex size-[calc(var(--newt-expressions-target-size)-var(--newt-space-2))] shrink-0 items-center justify-center overflow-hidden"
                >
                  {preview.item.src ? (
                    <img
                      src={preview.item.src}
                      alt=""
                      className="size-full object-contain"
                    />
                  ) : preview.item.kind === "emoji" ? (
                    <span className="text-[length:var(--newt-font-size-expression)] leading-none">
                      {expressionValue(preview.item, tone)}
                    </span>
                  ) : null}
                </span>
                <span className="flex min-w-0 flex-col">
                  <span className="truncate font-semibold">
                    {expressionShortcode(preview.item)}
                  </span>
                  {preview.where ? (
                    <span className="truncate text-[length:var(--newt-font-size-xs)] text-newt-text-secondary">
                      from{" "}
                      <strong className="font-semibold">{preview.where}</strong>
                    </span>
                  ) : null}
                </span>
              </>
            ) : (
              <span className="text-newt-text-secondary">
                Hover or focus an expression to preview it.
              </span>
            )}
          </div>
        </dialog>
      </div>
    )
  }
)
ExpressionsPicker.displayName = "ExpressionsPicker"
export { ExpressionsPicker }
<script setup lang="ts">
import {
  computed,
  nextTick,
  onBeforeUnmount,
  shallowRef,
  useId,
  useTemplateRef,
  watch,
} from "vue"

import { cn } from "@/lib/utils"

import {
  buildExpressionSections,
  expressionKinds,
  expressionShortcode,
  expressionsFocusRing,
  expressionsPickerVariants,
  expressionValue,
  neighbourCell,
  skinTones,
  type ExpressionCategory,
  type ExpressionItem,
  type ExpressionKind,
  type SkinTone,
} from "."
import ExpressionArt from "./ExpressionArt.vue"

const props = withDefaults(
  defineProps<{
    items?: readonly ExpressionItem[]
    categories?: readonly ExpressionCategory[]
    /** Item IDs used last, most recent first. The application stores and updates them. */
    recent?: readonly string[]
    /** The value chosen now, marked as pressed in the grid. */
    current?: string | null
    label?: string
    disabled?: boolean
  }>(),
  {
    items: () => [],
    categories: () => [],
    recent: () => [],
    current: null,
    label: "Choose an expression",
    disabled: false,
  }
)
/** Receives the item and its value in the chosen skin tone. */
const emit = defineEmits<{ select: [item: ExpressionItem, value: string] }>()

const id = useId()
const dialog = useTemplateRef<HTMLDialogElement>("dialog")
const trigger = useTemplateRef<HTMLButtonElement>("trigger")
const search = useTemplateRef<HTMLInputElement>("search")
const scroller = useTemplateRef<HTMLDivElement>("scroller")
const toneButton = useTemplateRef<HTMLButtonElement>("toneButton")
const toneRoot = useTemplateRef<HTMLDivElement>("toneRoot")
const open = shallowRef(false)
const kind = shallowRef<ExpressionKind>("emoji")
const query = shallowRef("")
const tone = shallowRef<SkinTone>(0)
const showTones = shallowRef(false)
const collapsed = shallowRef<string[]>([])
const scrolledTo = shallowRef<string | null>(null)
const activeKey = shallowRef<string | null>(null)
const hovered = shallowRef<{ item: ExpressionItem; where: string } | null>(null)

const triggerClass = `min-h-[var(--newt-expressions-target-size)] cursor-pointer rounded-sm border border-newt-border bg-newt-bg-elevated px-[var(--newt-space-3)] py-[var(--newt-space-2)] font-sans text-[length:var(--newt-font-size-sm)] leading-[var(--newt-line-height-normal)] text-newt-text-primary enabled:hover:bg-newt-bg-hover enabled:active:bg-newt-bg-active disabled:cursor-not-allowed disabled:text-newt-text-muted ${expressionsFocusRing}`

const searching = computed(() => query.value.trim().length > 0)
const sections = computed(() =>
  buildExpressionSections(
    props.items,
    props.categories,
    props.recent,
    kind.value,
    query.value
  )
)
const isOpen = (key: string) => !collapsed.value.includes(key)
const activeSection = computed(() => scrolledTo.value ?? sections.value[0]?.key)
const tabKey = computed(() => {
  const keys = sections.value
    .filter((section) => isOpen(section.key))
    .flatMap((section) =>
      section.items
        .filter((item) => !item.disabled)
        .map((item) => `${section.key}|${item.id}`)
    )
  return activeKey.value && keys.includes(activeKey.value)
    ? activeKey.value
    : keys[0]
})
const resultCount = computed(() =>
  sections.value.reduce((count, section) => count + section.items.length, 0)
)
const toneSample = computed(() => skinTones[tone.value])

function where(item: ExpressionItem) {
  return (
    props.categories.find((category) => category.id === item.category)?.label ??
    ""
  )
}
function isCurrent(item: ExpressionItem) {
  return (
    props.current != null &&
    (item.value === props.current ||
      item.skins?.includes(props.current) === true)
  )
}
const currentItem = computed(() => {
  const item = props.items.find(
    (entry) => entry.kind === kind.value && isCurrent(entry)
  )
  return item ? { item, where: where(item) } : null
})
const preview = computed(() => hovered.value ?? currentItem.value)

function toggleSection(key: string) {
  collapsed.value = isOpen(key)
    ? [...collapsed.value, key]
    : collapsed.value.filter((entry) => entry !== key)
}
async function goTo(key: string) {
  collapsed.value = collapsed.value.filter((entry) => entry !== key)
  scrolledTo.value = key
  await nextTick()
  const section = scroller.value?.querySelector<HTMLElement>(
    `[data-section="${key}"]`
  )
  if (section && scroller.value) scroller.value.scrollTop = section.offsetTop
}
function onScroll() {
  const element = scroller.value
  if (!element) return
  let found: string | null = null
  for (const section of element.querySelectorAll<HTMLElement>(
    "[data-section]"
  )) {
    if (section.offsetTop > element.scrollTop + 8) break
    found = section.dataset.section ?? null
  }
  if (found) scrolledTo.value = found
}

function show() {
  if (props.disabled) return
  query.value = ""
  hovered.value = null
  showTones.value = false
  dialog.value?.showModal()
  open.value = true
  search.value?.focus()
}
function close() {
  dialog.value?.close()
}
function closed() {
  open.value = false
  showTones.value = false
  trigger.value?.focus()
}
watch(
  () => props.disabled,
  (disabled) => {
    if (disabled) close()
  }
)

function changeKind(next: ExpressionKind) {
  kind.value = next
  scrolledTo.value = null
  hovered.value = null
  showTones.value = false
  if (scroller.value) scroller.value.scrollTop = 0
}
function tabKeyDown(event: KeyboardEvent, index: number) {
  const target = event.currentTarget
  if (!(target instanceof HTMLElement)) return
  const rtl = getComputedStyle(target).direction === "rtl"
  const count = expressionKinds.length
  const next =
    event.key === "Home"
      ? 0
      : event.key === "End"
        ? count - 1
        : event.key === (rtl ? "ArrowLeft" : "ArrowRight")
          ? (index + 1) % count
          : event.key === (rtl ? "ArrowRight" : "ArrowLeft")
            ? (index + count - 1) % count
            : -1
  const nextTab = expressionKinds[next]
  if (!nextTab) return
  event.preventDefault()
  changeKind(nextTab.kind)
  dialog.value
    ?.querySelectorAll<HTMLButtonElement>('[role="tab"]')
    .item(next)
    ?.focus()
}
function gridKeyDown(event: KeyboardEvent) {
  const target = event.target
  if (!(target instanceof HTMLElement) || !target.matches("[data-cell]")) return
  const cells = [
    ...(scroller.value?.querySelectorAll<HTMLElement>(
      "[data-cell]:not(:disabled)"
    ) ?? []),
  ]
  const rtl = getComputedStyle(target).direction === "rtl"
  const next = neighbourCell(cells, cells.indexOf(target), event.key, rtl)
  if (!next) return
  event.preventDefault()
  next.focus()
  next.scrollIntoView({ block: "nearest" })
}
function focusFirstCell() {
  scroller.value
    ?.querySelector<HTMLElement>('[data-cell][tabindex="0"]')
    ?.focus()
}
function onDialogKeyDown(event: KeyboardEvent) {
  if (event.key === "Escape" && showTones.value) {
    event.preventDefault()
    showTones.value = false
    toneButton.value?.focus()
    return
  }
  if (event.key !== "Tab") return
  const controls = [
    ...(dialog.value?.querySelectorAll<HTMLElement>(
      "button:not(:disabled), input:not(:disabled)"
    ) ?? []),
  ].filter(
    (element) => element.tabIndex >= 0 && element.getClientRects().length > 0
  )
  const first = controls[0]
  const last = controls[controls.length - 1]
  if (event.shiftKey && document.activeElement === first) {
    event.preventDefault()
    last?.focus()
  } else if (!event.shiftKey && document.activeElement === last) {
    event.preventDefault()
    first?.focus()
  }
}

function pickTone(next: SkinTone) {
  tone.value = next
  showTones.value = false
  toneButton.value?.focus()
}
function outside(event: PointerEvent) {
  const target = event.target
  if (!(target instanceof Node) || !toneRoot.value?.contains(target))
    showTones.value = false
}
watch(showTones, async (shown) => {
  document.removeEventListener("pointerdown", outside)
  if (!shown) return
  document.addEventListener("pointerdown", outside)
  await nextTick()
  toneRoot.value?.querySelector<HTMLElement>('[aria-pressed="true"]')?.focus()
})
onBeforeUnmount(() => document.removeEventListener("pointerdown", outside))

function select(item: ExpressionItem) {
  if (props.disabled || item.disabled) return
  close()
  emit("select", item, expressionValue(item, tone.value))
}
function focusCell(key: string, item: ExpressionItem) {
  activeKey.value = key
  hovered.value = { item, where: where(item) }
}
function clearQuery() {
  query.value = ""
  search.value?.focus()
}
</script>

<template>
  <div class="inline-block">
    <button
      ref="trigger"
      type="button"
      :disabled="disabled"
      aria-haspopup="dialog"
      :aria-expanded="open"
      :aria-controls="id"
      :class="triggerClass"
      @click="show"
    >
      {{ label }}
    </button>
    <dialog
      :id="id"
      ref="dialog"
      :aria-label="label"
      class="m-auto box-border h-[min(var(--newt-expressions-height),calc(100dvh-2*var(--newt-space-3)))] w-[min(var(--newt-expressions-width),calc(100%-2*var(--newt-space-3)))] max-w-none overflow-hidden rounded-lg border border-newt-border bg-newt-bg-surface p-0 font-sans text-[length:var(--newt-font-size-sm)] leading-[var(--newt-line-height-normal)] text-newt-text-primary shadow-elevation-high backdrop:bg-[color-mix(in_srgb,var(--newt-bg-base)_80%,transparent)] open:flex open:flex-col"
      @close="closed"
      @keydown="onDialogKeyDown"
      @click.self="close"
    >
      <div
        class="flex shrink-0 items-center gap-[var(--newt-space-2)] px-[var(--newt-space-3)] pt-[var(--newt-space-3)]"
      >
        <div
          role="tablist"
          aria-label="Expression type"
          class="flex items-center gap-[var(--newt-space-2)]"
        >
          <button
            v-for="(tab, index) in expressionKinds"
            :id="`${id}-${tab.kind}`"
            :key="tab.kind"
            role="tab"
            type="button"
            :aria-selected="kind === tab.kind"
            :aria-controls="`${id}-panel`"
            :tabindex="kind === tab.kind ? 0 : -1"
            :class="expressionsPickerVariants({ selected: kind === tab.kind })"
            @click="changeKind(tab.kind)"
            @keydown="tabKeyDown($event, index)"
          >
            {{ tab.label }}
          </button>
        </div>
      </div>

      <div
        class="flex shrink-0 items-center gap-[var(--newt-space-2)] border-b border-newt-border p-[var(--newt-space-3)]"
      >
        <div class="relative min-w-0 flex-1">
          <input
            ref="search"
            v-model="query"
            type="text"
            autocomplete="off"
            spellcheck="false"
            aria-label="Search expressions"
            placeholder="Search by name or keyword"
            :class="`box-border h-[calc(var(--newt-expressions-target-size)-var(--newt-space-2))] w-full rounded-md border-0 bg-newt-bg-input pe-[calc(var(--newt-expressions-target-size)-var(--newt-space-2))] ps-[calc(var(--newt-space-3)*3)] font-sans text-[length:var(--newt-font-size-sm)] text-newt-text-primary placeholder:text-newt-text-muted ${expressionsFocusRing}`"
            @input="hovered = null"
            @keydown.down.prevent="focusFirstCell"
          />
          <svg
            aria-hidden="true"
            viewBox="0 0 16 16"
            class="pointer-events-none absolute start-[var(--newt-space-3)] top-1/2 size-[calc(var(--newt-space-3)+var(--newt-space-1))] -translate-y-1/2 fill-none stroke-newt-text-secondary stroke-2"
          >
            <circle cx="7" cy="7" r="5" />
            <path d="m11 11 4 4" stroke-linecap="round" />
          </svg>
          <button
            v-if="query"
            type="button"
            aria-label="Clear search"
            :class="
              cn(
                expressionsPickerVariants(),
                'absolute end-[var(--newt-space-1)] top-1/2 min-h-0 min-w-0 -translate-y-1/2 size-[calc(var(--newt-expressions-target-size)-var(--newt-space-3)-var(--newt-space-2))] p-0'
              )
            "
            @click="clearQuery"
          >
            <span aria-hidden="true">×</span>
          </button>
        </div>
        <div v-if="kind === 'emoji'" ref="toneRoot" class="relative shrink-0">
          <button
            ref="toneButton"
            type="button"
            :aria-label="`Skin tone: ${toneSample?.label}`"
            :aria-expanded="showTones"
            :class="
              cn(
                expressionsPickerVariants({ selected: showTones }),
                'p-0 no-underline'
              )
            "
            @click="showTones = !showTones"
          >
            <span
              aria-hidden="true"
              class="text-[length:var(--newt-font-size-expression)] leading-none"
              >{{ toneSample?.sample }}</span
            >
          </button>
          <div
            v-if="showTones"
            role="group"
            aria-label="Skin tone"
            class="absolute -end-[var(--newt-space-1)] -top-[var(--newt-space-1)] z-20 flex flex-col gap-[var(--newt-space-1)] rounded-md border border-newt-border bg-newt-bg-floating p-[var(--newt-space-1)] shadow-elevation-high"
          >
            <button
              v-for="option in skinTones"
              :key="option.tone"
              type="button"
              :aria-label="option.label"
              :title="option.label"
              :aria-pressed="tone === option.tone"
              :class="
                cn(
                  expressionsPickerVariants(),
                  'p-0 aria-pressed:bg-newt-bg-active aria-pressed:shadow-[inset_0_0_0_2px_var(--newt-brand)]'
                )
              "
              @click="pickTone(option.tone)"
            >
              <span
                aria-hidden="true"
                class="text-[length:var(--newt-font-size-expression)] leading-none"
                >{{ option.sample }}</span
              >
            </button>
          </div>
        </div>
      </div>

      <div
        :id="`${id}-panel`"
        role="tabpanel"
        :aria-labelledby="`${id}-${kind}`"
        class="flex min-h-0 flex-1"
      >
        <nav
          v-if="!searching && sections.length > 1"
          aria-label="Categories"
          class="flex w-[calc(var(--newt-expressions-target-size)+var(--newt-space-2))] shrink-0 flex-col items-center gap-[var(--newt-space-1)] overflow-y-auto overflow-x-hidden border-e border-newt-border p-[var(--newt-space-2)] ![scrollbar-width:none]"
        >
          <button
            v-for="section in sections"
            :key="section.key"
            type="button"
            :aria-label="section.label"
            :title="section.label"
            :aria-current="activeSection === section.key ? 'true' : undefined"
            :class="
              cn(
                expressionsPickerVariants(),
                'relative shrink-0 p-0',
                activeSection === section.key &&
                  `text-newt-text-primary before:absolute before:-start-[var(--newt-space-2)] before:top-1/2 before:h-[calc(var(--newt-space-3)+var(--newt-space-2))] before:w-[var(--newt-space-1)] before:-translate-y-1/2 before:rounded-e-full before:bg-newt-text-primary before:content-['']`
              )
            "
            @click="goTo(section.key)"
          >
            <img
              v-if="section.iconSrc"
              :src="section.iconSrc"
              alt=""
              class="size-[calc(var(--newt-expressions-target-size)-var(--newt-space-3)-var(--newt-space-1))] rounded-full"
            />
            <span
              v-else
              aria-hidden="true"
              class="text-[length:calc(var(--newt-font-size-expression)*0.625)] leading-none"
              >{{ section.glyph ?? section.label.slice(0, 1) }}</span
            >
          </button>
        </nav>

        <div
          ref="scroller"
          class="relative min-w-0 flex-1 overflow-y-auto px-[var(--newt-space-2)] pb-[var(--newt-space-2)]"
          @scroll.passive="onScroll"
          @keydown="gridKeyDown"
          @mouseleave="hovered = null"
        >
          <section
            v-for="section in sections"
            :key="section.key"
            :data-section="section.key"
            :aria-labelledby="`${id}-${section.key}-title`"
          >
            <h3
              :id="`${id}-${section.key}-title`"
              class="sticky top-0 z-10 m-0 bg-newt-bg-surface pb-[var(--newt-space-1)] pt-[var(--newt-space-2)] text-[length:var(--newt-font-size-sm)] font-semibold text-newt-text-secondary"
            >
              <button
                v-if="section.collapsible"
                type="button"
                :aria-expanded="isOpen(section.key)"
                :aria-controls="`${id}-${section.key}`"
                :class="`inline-flex max-w-full cursor-pointer items-center gap-[var(--newt-space-2)] rounded-sm border-0 bg-transparent px-[var(--newt-space-1)] font-[inherit] text-[inherit] hover:text-newt-text-primary ${expressionsFocusRing}`"
                @click="toggleSection(section.key)"
              >
                <img
                  v-if="section.iconSrc"
                  :src="section.iconSrc"
                  alt=""
                  class="size-[calc(var(--newt-space-3)+var(--newt-space-1))] shrink-0 rounded-full"
                />
                <span
                  v-else-if="section.glyph"
                  aria-hidden="true"
                  class="shrink-0 leading-none"
                  >{{ section.glyph }}</span
                >
                <span class="min-w-0 truncate">{{ section.label }}</span>
                <svg
                  aria-hidden="true"
                  viewBox="0 0 16 16"
                  :class="
                    cn(
                      'size-[var(--newt-space-3)] shrink-0 fill-none stroke-current stroke-2',
                      !isOpen(section.key) &&
                        '[transform:rotate(calc(-90deg*var(--newt-dir)))]'
                    )
                  "
                >
                  <path
                    d="m4 6 4 4 4-4"
                    stroke-linecap="round"
                    stroke-linejoin="round"
                  />
                </svg>
              </button>
              <span v-else class="px-[var(--newt-space-1)]">{{
                section.label
              }}</span>
            </h3>
            <div
              v-if="isOpen(section.key)"
              :id="`${id}-${section.key}`"
              :class="
                cn(
                  'grid pt-[var(--newt-space-1)]',
                  kind === 'emoji'
                    ? 'grid-cols-[repeat(auto-fill,var(--newt-expressions-target-size))]'
                    : 'grid-cols-[repeat(auto-fill,minmax(var(--newt-expressions-preview-size),1fr))] gap-[var(--newt-space-1)]'
                )
              "
            >
              <button
                v-for="item in section.items"
                :key="`${section.key}|${item.id}`"
                type="button"
                data-cell
                :disabled="disabled || item.disabled"
                :tabindex="`${section.key}|${item.id}` === tabKey ? 0 : -1"
                :aria-label="
                  item.disabled ? `${item.label}, unavailable` : item.label
                "
                :aria-pressed="isCurrent(item)"
                :class="
                  cn(
                    'flex cursor-pointer items-center justify-center rounded-md border border-transparent bg-transparent p-0 enabled:hover:bg-newt-bg-hover aria-pressed:bg-newt-bg-active aria-pressed:shadow-[inset_0_0_0_2px_var(--newt-brand)] disabled:cursor-not-allowed disabled:border-dashed disabled:border-newt-border disabled:opacity-50',
                    kind === 'emoji'
                      ? 'size-[var(--newt-expressions-target-size)]'
                      : 'aspect-square w-full',
                    expressionsFocusRing
                  )
                "
                @click="select(item)"
                @focus="focusCell(`${section.key}|${item.id}`, item)"
                @mouseenter="hovered = { item, where: where(item) }"
              >
                <ExpressionArt
                  :item="item"
                  :value="expressionValue(item, tone)"
                  :large="kind !== 'emoji'"
                />
              </button>
            </div>
          </section>
          <div
            v-if="sections.length === 0"
            role="status"
            class="flex flex-col items-center px-[var(--newt-space-3)] py-[calc(var(--newt-space-3)*3)] text-center text-newt-text-secondary"
          >
            <p class="m-0 font-semibold text-newt-text-primary">
              {{
                searching
                  ? "No expressions match your search."
                  : "Nothing here yet."
              }}
            </p>
            <p class="m-0">
              {{
                searching
                  ? "Try another word or another tab."
                  : "Try another tab."
              }}
            </p>
          </div>
          <p v-else-if="searching" role="status" class="sr-only">
            {{ resultCount === 1 ? "1 result" : `${resultCount} results` }}
          </p>
        </div>
      </div>

      <div
        class="flex h-[calc(var(--newt-expressions-target-size)+var(--newt-space-3))] shrink-0 items-center gap-[var(--newt-space-3)] border-t border-newt-border bg-newt-bg-floating px-[var(--newt-space-3)]"
      >
        <template v-if="preview">
          <span
            aria-hidden="true"
            class="flex size-[calc(var(--newt-expressions-target-size)-var(--newt-space-2))] shrink-0 items-center justify-center overflow-hidden"
          >
            <img
              v-if="preview.item.src"
              :src="preview.item.src"
              alt=""
              class="size-full object-contain"
            />
            <span
              v-else-if="preview.item.kind === 'emoji'"
              class="text-[length:var(--newt-font-size-expression)] leading-none"
              >{{ expressionValue(preview.item, tone) }}</span
            >
          </span>
          <span class="flex min-w-0 flex-col">
            <span class="truncate font-semibold">{{
              expressionShortcode(preview.item)
            }}</span>
            <span
              v-if="preview.where"
              class="truncate text-[length:var(--newt-font-size-xs)] text-newt-text-secondary"
              >from
              <strong class="font-semibold">{{ preview.where }}</strong></span
            >
          </span>
        </template>
        <span v-else class="text-newt-text-secondary"
          >Hover or focus an expression to preview it.</span
        >
      </div>
    </dialog>
  </div>
</template>
<script setup lang="ts">
import { cn } from "@/lib/utils"

import type { ExpressionItem } from "."

defineProps<{ item: ExpressionItem; value: string; large?: boolean }>()
</script>

<template>
  <img
    v-if="item.src"
    :src="item.src"
    alt=""
    loading="lazy"
    :class="
      cn(
        'object-contain',
        large
          ? 'size-[calc(var(--newt-expressions-preview-size)-var(--newt-space-3))]'
          : 'size-[calc(var(--newt-expressions-target-size)-var(--newt-space-2))]'
      )
    "
  />
  <span
    v-else-if="item.kind === 'emoji'"
    aria-hidden="true"
    class="text-[length:var(--newt-font-size-expression)] leading-none"
    >{{ value }}</span
  >
  <span
    v-else
    aria-hidden="true"
    class="px-[var(--newt-space-1)] text-center text-[length:var(--newt-font-size-xs)] text-newt-text-secondary"
    >{{ item.label }}</span
  >
</template>
import { cva } from "class-variance-authority"
export { default as ExpressionsPicker } from "./ExpressionsPicker.vue"

export type ExpressionKind = "emoji" | "sticker" | "gif"

/** A section of one tab: a Unicode emoji group, a server's own emoji, or a sticker pack. */
export interface ExpressionCategory {
  /** Unique across all categories. */
  id: string
  kind: ExpressionKind
  label: string
  /** A Unicode character for the category rail. */
  glyph?: string
  /** A static image for the category rail, such as a server icon. Wins over `glyph`. */
  iconSrc?: string
}

export interface ExpressionItem {
  /** Unique across all items. */
  id: string
  kind: ExpressionKind
  /** The `id` of the category this item belongs to. */
  category: string
  /** Accessible name, searched and shown in the preview bar. */
  label: string
  /** What a selection returns: Unicode text, or an application token for media. */
  value: string
  keywords?: readonly string[]
  /** The emoji in the five skin tones, light to dark. Omit when it has none. */
  skins?: readonly string[]
  /** A static image: a server emoji, a sticker, or a GIF poster. */
  src?: string
  disabled?: boolean
}

export type SkinTone = 0 | 1 | 2 | 3 | 4 | 5

export const expressionKinds: { kind: ExpressionKind; label: string }[] = [
  { kind: "gif", label: "GIFs" },
  { kind: "sticker", label: "Stickers" },
  { kind: "emoji", label: "Emoji" },
]

export const skinTones: { tone: SkinTone; label: string; sample: string }[] = [
  { tone: 0, label: "Default skin tone", sample: "👋" },
  { tone: 1, label: "Light skin tone", sample: "👋🏻" },
  { tone: 2, label: "Medium-light skin tone", sample: "👋🏼" },
  { tone: 3, label: "Medium skin tone", sample: "👋🏽" },
  { tone: 4, label: "Medium-dark skin tone", sample: "👋🏾" },
  { tone: 5, label: "Dark skin tone", sample: "👋🏿" },
]

/** The value of an item in a skin tone, or its own value when it has none. */
export function expressionValue(item: ExpressionItem, tone: SkinTone): string {
  return (tone === 0 ? undefined : item.skins?.[tone - 1]) ?? item.value
}

/** `:grinning_face:` for an emoji, the label for media. */
export function expressionShortcode(item: ExpressionItem): string {
  if (item.kind !== "emoji") return item.label
  const name = item.label
    .toLowerCase()
    .replaceAll(/[^\p{L}\p{N}]+/gu, "_")
    .replaceAll(/^_+|_+$/gu, "")
  return `:${name}:`
}

/** Every word of the query must appear in the label or the keywords. */
export function matchesExpression(item: ExpressionItem, query: string) {
  const text = [item.label, ...(item.keywords ?? [])].join(" ").toLowerCase()
  return query
    .toLowerCase()
    .split(/[\s_:]+/u)
    .filter(Boolean)
    .every((word) => text.includes(word))
}

export interface ExpressionSection {
  key: string
  label: string
  glyph?: string
  iconSrc?: string
  items: ExpressionItem[]
  collapsible: boolean
}

export function buildExpressionSections(
  items: readonly ExpressionItem[],
  categories: readonly ExpressionCategory[],
  recent: readonly string[],
  kind: ExpressionKind,
  query: string
): ExpressionSection[] {
  const ofKind = items.filter((item) => item.kind === kind)
  if (query.trim()) {
    const found = ofKind.filter((item) => matchesExpression(item, query))
    return found.length
      ? [
          {
            key: "results",
            label: "Search results",
            items: found,
            collapsible: false,
          },
        ]
      : []
  }
  const byId = new Map(ofKind.map((item) => [item.id, item]))
  const sections: ExpressionSection[] = []
  const used = recent.flatMap((id) => {
    const item = byId.get(id)
    return item ? [item] : []
  })
  if (used.length)
    sections.push({
      key: "recent",
      label: "Recently used",
      glyph: "🕘",
      items: used,
      collapsible: true,
    })
  for (const category of categories) {
    if (category.kind !== kind) continue
    const list = ofKind.filter((item) => item.category === category.id)
    if (list.length)
      sections.push({
        key: `category-${category.id}`,
        label: category.label,
        glyph: category.glyph,
        iconSrc: category.iconSrc,
        items: list,
        collapsible: true,
      })
  }
  return sections
}

/** The cell an arrow key leads to, by where the cells sit on screen. */
export function neighbourCell(
  cells: HTMLElement[],
  index: number,
  key: string,
  rtl: boolean
): HTMLElement | undefined {
  if (key === (rtl ? "ArrowLeft" : "ArrowRight")) return cells[index + 1]
  if (key === (rtl ? "ArrowRight" : "ArrowLeft")) return cells[index - 1]
  if (key === "Home") return cells[0]
  if (key === "End") return cells.at(-1)
  if (key !== "ArrowDown" && key !== "ArrowUp") return undefined
  const from = cells[index]?.getBoundingClientRect()
  if (!from) return undefined
  const step = key === "ArrowDown" ? 1 : -1
  let rowTop: number | null = null
  let best: { cell: HTMLElement; distance: number } | undefined
  for (let at = index + step; at >= 0 && at < cells.length; at += step) {
    const cell = cells[at]
    if (!cell) break
    const rect = cell.getBoundingClientRect()
    if (Math.abs(rect.top - from.top) < 4) continue
    rowTop ??= rect.top
    if (Math.abs(rect.top - rowTop) >= 4) break
    const distance = Math.abs(rect.left - from.left)
    if (!best || distance < best.distance) best = { cell, distance }
  }
  return best?.cell
}

export const expressionsFocusRing =
  "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-newt-text-link"

/** Tabs and the small controls of the panel. */
export const expressionsPickerVariants = cva(
  `inline-flex min-h-[calc(var(--newt-expressions-target-size)-var(--newt-space-2))] min-w-[calc(var(--newt-expressions-target-size)-var(--newt-space-2))] cursor-pointer items-center justify-center gap-[var(--newt-space-1)] rounded-md border-0 px-[var(--newt-space-3)] font-sans text-[length:var(--newt-font-size-sm)] font-semibold leading-[var(--newt-line-height-normal)] enabled:hover:bg-newt-bg-hover enabled:hover:text-newt-text-primary enabled:active:bg-newt-bg-active disabled:cursor-not-allowed disabled:text-newt-text-muted ${expressionsFocusRing}`,
  {
    variants: {
      selected: {
        true: "bg-newt-bg-active text-newt-text-primary",
        false: "bg-transparent text-newt-text-secondary",
      },
    },
    defaultVariants: { selected: false },
  }
)

Update the import paths

The files import @/lib/utils and sibling components through the aliases in your components.json. Point them at wherever those live in your project.

Usage

import { ExpressionsPicker } from "@/components/ui/expressions-picker"

export function Example() {
  return <ExpressionsPicker />
}
<script setup lang="ts">
import { ExpressionsPicker, ExpressionArt } from "@/components/ui/expressions-picker"
</script>

<template>
  <ExpressionsPicker />
</template>

React receives selections through onSelect(item, value); Vue emits select with the same two arguments. value is the item’s value in the chosen skin tone, or item.value when it has no tones. Every selection closes the dialog. Store value as the current choice and move item.id to the front of recent yourself; the demo keeps the last 18.

API reference

ExpressionsPicker

PropType
items?readonly ExpressionItem[]

Available expressions. Each tab shows the items of its kind, grouped by category in the order of `categories`.

Typereadonly ExpressionItem[]
Default[]
categories?readonly ExpressionCategory[]

The sections of each tab, in display order. Items whose category is missing are not shown.

Typereadonly ExpressionCategory[]
Default[]
recent?readonly string[]

Item IDs used last, most recent first. Shown as a "Recently used" section above the categories.

Typereadonly string[]
Default[]
current?string | null

The value chosen now. Matching cells are pressed, and the foot bar names it while nothing is hovered.

Typestring | null
Defaultnull
label?string

Trigger text and accessible dialog name.

Typestring
DefaultChoose an expression
disabled?boolean

Disables the trigger and closes an open picker.

Typeboolean
Defaultfalse
onSelect / @select?(item: ExpressionItem, value: string) => void

Receives an enabled item and its value in the chosen skin tone.

Type(item: ExpressionItem, value: string) => void
className / class?string

Classes applied to the outer wrapper.

Typestring

ExpressionItem

Field Type Meaning
id string Stable ID, unique across the complete list. recent refers to it.
kind "emoji" | "sticker" | "gif" Which tab contains this item.
category string The id of its ExpressionCategory.
label string Accessible name, searched, and shown in the foot bar. Emoji show it as :snake_case:.
value string What a selection returns: Unicode text, or an application token for server emoji and media.
keywords readonly string[] Optional search aliases. Every word of a search must match the label or a keyword.
skins readonly string[] Optional: the emoji in the five skin tones, light to dark. Without it the tone does not apply.
src string Optional static image: a server emoji, a sticker, or a GIF poster. Never fetched as media.
disabled boolean Optional unavailable state, shown dimmed with a dashed border and announced as unavailable.

ExpressionCategory

Field Type Meaning
id string Unique across all categories.
kind "emoji" | "sticker" | "gif" Which tab shows this section.
label string Section title, rail button name, and source named in the foot bar.
glyph string Optional Unicode character for the rail button.
iconSrc string Optional static image for the rail button, such as a server icon.

Use a dataset such as Emojibase to build items for the full Unicode set; group its entries into categories and pass its skin tone variants as skins. Images must be still and yours to use. The picker never renders value as a media source.

expressionsPickerVariants({ selected }) exports the style of the tabs and small controls. The selected variant fills the control with the active surface.

HTML and CSS

Load tokens.css, expressions-picker.css, and expressions-picker.js with defer, after copying the example markup. Put the categories, items, and recent IDs as JSON in the data-newt-expressions-data script; the fields are the same as above. The script draws the rail, sections, and foot bar, and initializes each data-newt-expressions-picker root separately with unique ARIA IDs.

Listen for the bubbling newt:expression-select event on the root. event.detail holds item, value, and recent, the updated list of IDs to persist. The script keeps that list for the life of the page and marks the last choice as pressed. Disable the trigger to disable opening the picker, and close the dialog before disabling an open one.

Accessibility

The native modal dialog contains keyboard focus and makes the surrounding page inert. Opening focuses the search field; Down Arrow moves from it into the grid. Escape closes the skin tone list first, then the dialog. A click on the backdrop also closes it. Closing or selecting returns focus to the trigger.

The tab list uses automatic activation: Left and Right move between types in reading direction, and Home and End select the first and last tab. The selected tab is a filled pill, not only a color change.

The grid keeps one cell in the tab order. Arrow keys move by position on screen, following the reading direction, and Home and End jump to the first and last cell. Each cell is a button named by its label; the current choice is aria-pressed. Section titles are buttons with aria-expanded, and the rail is a navigation landmark whose buttons name their section and mark the visible one with aria-current and a bar on the rail’s start edge. The skin tone list is a group of pressed-state buttons. A polite status announces the number of search results and explains empty searches.

Every focus indicator uses a 2px link-color outline with a 2px offset. The layout follows inherited dir, scales down to narrow screens, and uses no motion: jumping to a section scrolls instantly, and a collapsed section’s chevron is a static rotation that points along the reading direction. The rail hides its scrollbar but still scrolls with the wheel, touch, and keyboard. Unicode emoji appearance depends on the operating system.

Tokens

Expressions Picker reads its colours, radii and motion from the --newt-* tokens installed with the library. Override a token and every component that reads it follows, including this one — see the token layer. In the plain HTML and CSS distribution the same component is newt-expressions-picker.

The picker uses the existing surface, border, text, brand, font, radius, and elevation tokens. New shared tokens are --newt-space-1 (4px), --newt-space-2 (8px), --newt-space-3 (12px), --newt-font-size-xs (12px), --newt-font-size-sm (14px), --newt-font-size-expression (32px), and --newt-line-height-normal (1.5). Picker geometry uses --newt-expressions-width (512px), --newt-expressions-height (496px), --newt-expressions-target-size (48px, an emoji cell), and --newt-expressions-preview-size (96px, the smallest sticker or GIF cell).

These additions are backward compatible. Existing installations should refresh theme-newt or copy the new token declarations before adding this picker.