Skip to content

Entity Select

The picker for a server's own members, roles and channels, with chips when more than one may be chosen.

The picker for the things a server already knows about: its members, its roles, its channels, or all of them at once. It is a separate control from the string select rather than a mode of it, because the rows are not strings — a member arrives with a face, a role with a colour and a headcount, a channel with the glyph that says what kind of channel it is.

Root class newt-entity-select
"use client"

import * as React from "react"

import { EntitySelect } from "@/registry/bases/newt/ui/entity-select"

/* A face, drawn rather than fetched, so the demo holds up offline. */
function avatar(hue: number) {
  return (
    <span
      className="h-full w-full rounded-full"
      style={{ background: `hsl(${hue} 60% 45%)` }}
    />
  )
}

/* The glyph a text channel is known by. */
const hash = (
  <svg viewBox="0 0 24 24" className="h-full w-full text-newt-text-muted">
    <path
      fill="currentColor"
      d="M10.2 3a1 1 0 0 1 1 1.15L10.83 7h3.98l.43-2.85a1 1 0 1 1 1.98.3L16.83 7H19a1 1 0 1 1 0 2h-2.47l-.6 4H18a1 1 0 1 1 0 2h-2.37l-.44 2.85a1 1 0 1 1-1.98-.3l.39-2.55h-3.98l-.43 2.85a1 1 0 1 1-1.98-.3L7.6 15H5a1 1 0 1 1 0-2h2.9l.6-4H6a1 1 0 0 1 0-2h2.8l.43-2.85A1 1 0 0 1 10.2 3Zm-.7 6-.6 4h3.98l.6-4H9.5Z"
    />
  </svg>
)

export default function EntitySelectDemo() {
  const [chosen, setChosen] = React.useState<readonly string[]>([])

  return (
    <div className="flex w-full max-w-[420px] flex-col gap-3">
      <EntitySelect
        kind="user"
        label="Choose a user"
        placeholder="Select a user"
        value={chosen}
        onValueChange={setChosen}
        options={[
          { value: "1", label: "someone", icon: avatar(210) },
          { value: "2", label: "a helper", icon: avatar(280), badge: "App" },
          { value: "3", label: "another", icon: avatar(140) },
        ]}
      />
      <EntitySelect
        kind="channel"
        label="Which text channel?"
        placeholder="Which text channel?"
        options={[
          { value: "general", label: "general", icon: hash },
          { value: "dev-chat", label: "dev-chat", icon: hash },
          { value: "playtesting", label: "playtesting", icon: hash },
        ]}
      />
    </div>
  )
}
<script setup lang="ts">
import { h, markRaw, ref } from "vue"
import { EntitySelect } from "~~/registry/bases/newt/ui/entity-select"

/* A face, drawn rather than fetched, so the demo holds up offline. */
function avatar(hue: number) {
  return markRaw({
    render: () =>
      h("span", {
        class: "h-full w-full rounded-full",
        style: { background: `hsl(${hue} 60% 45%)` },
      }),
  })
}

/* The glyph a text channel is known by. */
const Hash = markRaw({
  render: () =>
    h(
      "svg",
      { viewBox: "0 0 24 24", class: "h-full w-full text-newt-text-muted" },
      [
        h("path", {
          fill: "currentColor",
          d: "M10.2 3a1 1 0 0 1 1 1.15L10.83 7h3.98l.43-2.85a1 1 0 1 1 1.98.3L16.83 7H19a1 1 0 1 1 0 2h-2.47l-.6 4H18a1 1 0 1 1 0 2h-2.37l-.44 2.85a1 1 0 1 1-1.98-.3l.39-2.55h-3.98l-.43 2.85a1 1 0 1 1-1.98-.3L7.6 15H5a1 1 0 1 1 0-2h2.9l.6-4H6a1 1 0 0 1 0-2h2.8l.43-2.85A1 1 0 0 1 10.2 3Zm-.7 6-.6 4h3.98l.6-4H9.5Z",
        }),
      ]
    ),
})

const chosen = ref<readonly string[]>([])

const people = [
  { value: "1", label: "someone", icon: avatar(210) },
  { value: "2", label: "a helper", icon: avatar(280), badge: "App" },
  { value: "3", label: "another", icon: avatar(140) },
]

const channels = [
  { value: "general", label: "general", icon: Hash },
  { value: "dev-chat", label: "dev-chat", icon: Hash },
  { value: "playtesting", label: "playtesting", icon: Hash },
]
</script>

<template>
  <div class="flex w-full max-w-[420px] flex-col gap-3">
    <EntitySelect
      v-model="chosen"
      kind="user"
      label="Choose a user"
      placeholder="Select a user"
      :options="people"
    />
    <EntitySelect
      kind="channel"
      label="Which text channel?"
      placeholder="Which text channel?"
      :options="channels"
    />
  </div>
</template>

More than one

max above one turns the control into chips: the choices leave the list and sit in the trigger, each with a cross, and every row grows a box instead of a tick — the shape is what says whether picking this one un-picks the last. Backspace removes the chip nearest the caret.

At the limit the list stays open and stops taking more, rather than silently dropping the oldest choice: somebody picked those, and a control that quietly un-picks one is a control that lies.

Root class newt-entity-select
"use client"

import * as React from "react"

import { EntitySelect } from "@/registry/bases/newt/ui/entity-select"

/*
 * More than one may be chosen, so the choices leave the list and sit in the
 * control as chips — and each row grows a box instead of a tick.
 */
export default function EntitySelectRolesDemo() {
  const [roles, setRoles] = React.useState<readonly string[]>(["mod"])

  return (
    <EntitySelect
      kind="role"
      max={3}
      label="Which roles?"
      placeholder="Which roles?"
      value={roles}
      onValueChange={setRoles}
      options={[
        { value: "mod", label: "Moderator", color: "#f0b232", count: 4 },
        { value: "dev", label: "Contributor", color: "#3ba55d", count: 12 },
        { value: "player", label: "Player", color: "#5865f2", count: 128 },
        { value: "muted", label: "Muted", color: "#949ba4", count: 0 },
      ]}
    />
  )
}
<script setup lang="ts">
import { ref } from "vue"
import { EntitySelect } from "~~/registry/bases/newt/ui/entity-select"

/*
 * More than one may be chosen, so the choices leave the list and sit in the
 * control as chips — and each row grows a box instead of a tick.
 */
const roles = ref<readonly string[]>(["mod"])

const options = [
  { value: "mod", label: "Moderator", color: "#f0b232", count: 4 },
  { value: "dev", label: "Contributor", color: "#3ba55d", count: 12 },
  { value: "player", label: "Player", color: "#5865f2", count: 128 },
  { value: "muted", label: "Muted", color: "#949ba4", count: 0 },
]
</script>

<template>
  <EntitySelect
    v-model="roles"
    kind="role"
    :max="3"
    label="Which roles?"
    placeholder="Which roles?"
    :options="options"
  />
</template>

Installation

pnpm dlx newtui@latest add entity-select
npx newtui@latest add entity-select
yarn dlx newtui@latest add entity-select
bunx newtui@latest add entity-select

Copy the source into your project

"use client"

import * as React from "react"
import { createPortal } from "react-dom"

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

/*
 * The picker for things the server already knows about: its members, its
 * roles, its channels, or all of them at once.
 *
 * It is a separate control from the string select rather than a mode of it,
 * because the rows are not strings. A member arrives with a face, a role with
 * a colour and a headcount, a channel with the glyph that says what kind of
 * channel it is — and when more than one can be chosen, the choices come back
 * out of the list and sit in the control as chips, which a string select
 * never does.
 */

/** What the list is drawn from. Mentionable mixes members and roles. */
export type EntityKind = "user" | "role" | "channel" | "mentionable"

export interface EntityOption {
  value: string
  label: string
  /** A member's avatar, or the glyph a role or channel is known by. */
  icon?: React.ReactNode
  /** Members carrying this role — shown beside the name, as the client does. */
  count?: number
  /** A short tag after the name. An app is the one that always has one. */
  badge?: string
  /** A role's colour, which its name takes in the list and in its chip. */
  color?: string
  disabled?: boolean
}

/**
 * What one of these may carry. The platform takes at most this many either
 * way — as the list it offers, and as the selection it accepts back.
 */
export const ENTITY_SELECT_MAX = 25

export interface EntitySelectProps extends Omit<
  React.HTMLAttributes<HTMLDivElement>,
  "onSelect" | "defaultValue"
> {
  options: readonly EntityOption[]
  /** Which of the four this is. Only the empty-list wording depends on it. */
  kind?: EntityKind
  /** Controlled selection. Leave undefined to let the control own it. */
  value?: readonly string[]
  defaultValue?: readonly string[]
  onValueChange?: (value: readonly string[]) => void
  /** How many may be chosen. More than one turns the control into chips. */
  max?: number
  placeholder?: string
  disabled?: boolean
  /** Accessible name of the trigger and the listbox. */
  label?: string
}

/** Panel geometry, measured from the control each time the list opens. */
interface PanelBox {
  left: number
  width: number
  top?: number
  bottom?: number
}

const EMPTY = {
  user: "No members to show",
  role: "No roles to show",
  channel: "No channels to show",
  mentionable: "Nothing to show",
} as const satisfies Record<EntityKind, string>

/** The headcount beside a role, which is a number and the glyph for people. */
function Headcount({ count }: { readonly count: number }) {
  return (
    <span className="flex shrink-0 items-center gap-1 text-xs tabular-nums text-newt-text-muted">
      <svg viewBox="0 0 24 24" aria-hidden="true" className="h-3.5 w-3.5">
        <path
          fill="currentColor"
          d="M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm0 2c-3.9 0-7 2-7 4.5V20h14v-1.5c0-2.5-3.1-4.5-7-4.5Z"
        />
      </svg>
      {count}
    </span>
  )
}

const EntitySelect = React.forwardRef<HTMLDivElement, EntitySelectProps>(
  (
    {
      className,
      options: given,
      kind = "user",
      value,
      defaultValue = [],
      onValueChange,
      max = 1,
      placeholder = "Make a selection",
      disabled = false,
      label = "Select an option",
      ...props
    },
    ref
  ) => {
    /* The cap is applied here: the keyboard and the option ids count rows, and
     * a row the platform would reject should not be one of them. */
    const options = React.useMemo(
      () => given.slice(0, ENTITY_SELECT_MAX),
      [given]
    )
    const limit = Math.min(max, ENTITY_SELECT_MAX)
    const multiple = limit > 1

    const id = React.useId()
    const listboxId = `${id}-listbox`
    const optionId = (index: number) => `${id}-option-${index}`

    const triggerRef = React.useRef<HTMLButtonElement>(null)
    const panelRef = React.useRef<HTMLDivElement>(null)

    const [open, setOpen] = React.useState(false)
    const [active, setActive] = React.useState(-1)
    const [box, setBox] = React.useState<PanelBox | null>(null)
    const [uncontrolled, setUncontrolled] =
      React.useState<readonly string[]>(defaultValue)
    const selected = value ?? uncontrolled

    const chosen = React.useMemo(
      () => options.filter((option) => selected.includes(option.value)),
      [options, selected]
    )

    const commit = React.useCallback(
      (next: readonly string[]) => {
        if (value === undefined) setUncontrolled(next)
        onValueChange?.(next)
      },
      [value, onValueChange]
    )

    const selectable = React.useCallback(
      (index: number) => Boolean(options[index]) && !options[index].disabled,
      [options]
    )

    const firstSelectable = React.useCallback(
      () => options.findIndex((option) => !option.disabled),
      [options]
    )

    const lastSelectable = React.useCallback(() => {
      for (let i = options.length - 1; i >= 0; i--) {
        if (!options[i].disabled) return i
      }
      return -1
    }, [options])

    const step = React.useCallback(
      (from: number, direction: 1 | -1) => {
        const count = options.length
        for (let i = 1; i <= count; i++) {
          const index = (from + direction * i + count * i) % count
          if (selectable(index)) return index
        }
        return from
      },
      [options.length, selectable]
    )

    /*
     * The panel is portalled to the body so an `overflow: hidden` ancestor
     * cannot clip it, which means it is positioned from measured coordinates
     * and flipped above the control when the space below runs out.
     */
    const measure = React.useCallback(() => {
      const trigger = triggerRef.current
      if (!trigger) return
      const rect = trigger.getBoundingClientRect()
      const below = window.innerHeight - rect.bottom
      const height = panelRef.current?.offsetHeight ?? 0
      setBox(
        height > below && rect.top > below
          ? {
              left: rect.left,
              width: rect.width,
              bottom: window.innerHeight - rect.top + 4,
            }
          : { left: rect.left, width: rect.width, top: rect.bottom + 4 }
      )
    }, [])

    React.useLayoutEffect(() => {
      if (!open) return
      measure()
    }, [open, measure])

    React.useEffect(() => {
      if (!open) return
      const update = () => measure()
      /* Capture phase: a nested scroller never fires a window scroll event. */
      document.addEventListener("scroll", update, {
        capture: true,
        passive: true,
      })
      window.addEventListener("resize", update)
      return () => {
        document.removeEventListener("scroll", update, { capture: true })
        window.removeEventListener("resize", update)
      }
    }, [open, measure])

    React.useEffect(() => {
      if (!open) return
      const onPointerDown = (event: MouseEvent) => {
        const target = event.target
        if (!(target instanceof Node)) return
        if (
          triggerRef.current?.contains(target) ||
          panelRef.current?.contains(target)
        ) {
          return
        }
        setOpen(false)
      }
      document.addEventListener("mousedown", onPointerDown)
      return () => document.removeEventListener("mousedown", onPointerDown)
    }, [open])

    React.useEffect(() => {
      if (active < 0) return
      panelRef.current
        ?.querySelector(`#${CSS.escape(optionId(active))}`)
        ?.scrollIntoView({ block: "nearest" })
      // eslint-disable-next-line react-hooks/exhaustive-deps -- optionId is derived from a stable id
    }, [active])

    const openMenu = () => {
      if (disabled || open) return
      setOpen(true)
      const current = options.findIndex((option) =>
        selected.includes(option.value)
      )
      setActive(
        current >= 0 && selectable(current) ? current : firstSelectable()
      )
    }

    const closeMenu = () => {
      setOpen(false)
      setActive(-1)
    }

    const toggle = (index: number) => {
      const option = options[index]
      if (!option || option.disabled) return
      if (!multiple) {
        commit([option.value])
        closeMenu()
        triggerRef.current?.focus()
        return
      }
      /*
       * At the limit the list stays open and stops taking more, rather than
       * silently dropping the oldest choice: the reader picked those, and a
       * control that quietly un-picks one is a control that lies.
       */
      const already = selected.includes(option.value)
      if (!already && selected.length >= limit) return
      commit(
        already
          ? selected.filter((each) => each !== option.value)
          : [...selected, option.value]
      )
    }

    const remove = (entity: string) => {
      commit(selected.filter((each) => each !== entity))
    }

    const onKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
      switch (event.key) {
        case "ArrowDown":
        case "ArrowUp": {
          event.preventDefault()
          if (!open) {
            openMenu()
            return
          }
          setActive((current) =>
            step(current, event.key === "ArrowDown" ? 1 : -1)
          )
          return
        }
        case "Home":
          if (!open) return
          event.preventDefault()
          setActive(firstSelectable())
          return
        case "End":
          if (!open) return
          event.preventDefault()
          setActive(lastSelectable())
          return
        case "Enter":
        case " ": {
          event.preventDefault()
          if (!open) {
            openMenu()
            return
          }
          if (active >= 0) toggle(active)
          return
        }
        case "Escape":
          if (!open) return
          event.preventDefault()
          closeMenu()
          triggerRef.current?.focus()
          return
        case "Backspace": {
          /* The chip nearest the caret goes first, which is the last one. */
          if (!multiple || open || chosen.length === 0) return
          event.preventDefault()
          const last = chosen[chosen.length - 1]
          if (last) remove(last.value)
          return
        }
        default:
      }
    }

    return (
      <div
        ref={ref}
        className={cn("w-full max-w-[420px]", className)}
        {...props}
      >
        <button
          ref={triggerRef}
          type="button"
          role="combobox"
          aria-haspopup="listbox"
          aria-expanded={open}
          aria-controls={open ? listboxId : undefined}
          aria-activedescendant={
            open && active >= 0 ? optionId(active) : undefined
          }
          aria-label={label}
          disabled={disabled}
          onClick={() => (open ? closeMenu() : openMenu())}
          onKeyDown={onKeyDown}
          className={cn(
            "flex w-full items-center gap-2 rounded-md border border-newt-border bg-newt-bg-base px-3 py-2 text-start text-sm text-newt-text-secondary",
            "transition-colors duration-fast ease-newt hover:border-newt-text-muted",
            "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-newt-text-link",
            open && "border-newt-text-link",
            disabled && "cursor-not-allowed opacity-50"
          )}
        >
          <span className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
            {chosen.length === 0 ? (
              <span className="text-newt-text-muted">{placeholder}</span>
            ) : multiple ? (
              chosen.map((option) => (
                /*
                 * A chip is not a button: it sits inside one, and nesting two
                 * would be markup a keyboard cannot untangle. Its cross is
                 * decorative, and Backspace is the way in from the keyboard.
                 */
                <span
                  key={option.value}
                  onMouseDown={(event) => {
                    event.preventDefault()
                    event.stopPropagation()
                    remove(option.value)
                  }}
                  className="flex items-center gap-1.5 rounded-sm bg-newt-bg-elevated px-1.5 py-1 text-xs text-newt-text-primary"
                >
                  {option.icon ? (
                    <span aria-hidden="true" className="flex h-4 w-4 shrink-0">
                      {option.icon}
                    </span>
                  ) : null}
                  <span
                    style={option.color ? { color: option.color } : undefined}
                  >
                    {option.label}
                  </span>
                  <svg
                    viewBox="0 0 24 24"
                    aria-hidden="true"
                    className="h-3 w-3"
                  >
                    <path
                      fill="currentColor"
                      d="M7.05 5.64a1 1 0 0 1 1.41 0L12 9.17l3.54-3.53a1 1 0 1 1 1.41 1.41L13.41 10.6l3.54 3.53a1 1 0 0 1-1.41 1.42L12 12.01l-3.54 3.54a1 1 0 0 1-1.41-1.42l3.53-3.53-3.53-3.54a1 1 0 0 1 0-1.42Z"
                    />
                  </svg>
                </span>
              ))
            ) : (
              <span className="flex min-w-0 items-center gap-2">
                {chosen[0]?.icon ? (
                  <span aria-hidden="true" className="flex h-5 w-5 shrink-0">
                    {chosen[0].icon}
                  </span>
                ) : null}
                <span
                  className="truncate text-newt-text-primary"
                  style={
                    chosen[0]?.color ? { color: chosen[0].color } : undefined
                  }
                >
                  {chosen[0]?.label}
                </span>
              </span>
            )}
          </span>

          <svg
            viewBox="0 0 24 24"
            aria-hidden="true"
            className={cn("h-[18px] w-[18px] shrink-0", open && "rotate-180")}
          >
            <path
              fill="currentColor"
              d="M5.3 9.3a1 1 0 0 1 1.4 0l5.3 5.29 5.3-5.3a1 1 0 1 1 1.4 1.42l-6 6a1 1 0 0 1-1.4 0l-6-6a1 1 0 0 1 0-1.42Z"
            />
          </svg>
        </button>

        {/* The panel exists only once it is opened, which is only ever after
         * hydration — so reaching for `document` here is safe on a server
         * render, where `open` has never been true. */}
        {open
          ? createPortal(
              <div
                ref={panelRef}
                /*
                 * `fixed` from the first frame, before there is anything to be
                 * fixed at: a panel that started in the document flow would
                 * sit at the end of the body, and scrolling its first row into
                 * view would drag the whole page down to it. Hidden until
                 * measured, so that frame is never seen.
                 */
                style={{
                  position: "fixed",
                  visibility: box === null ? "hidden" : undefined,
                  left: box?.left,
                  width: box?.width,
                  top: box?.top,
                  bottom: box?.bottom,
                  zIndex: 1002,
                }}
                /* Keep focus on the control: aria-activedescendant drives the
                 * list, so the panel must never take it. */
                onMouseDown={(event) => event.preventDefault()}
                className="rounded-md border border-newt-border bg-newt-bg-floating p-1.5 shadow-elevation-high"
              >
                <ul
                  id={listboxId}
                  role="listbox"
                  aria-label={label}
                  aria-multiselectable={multiple || undefined}
                  /*
                   * The scrollbar shows rather than hiding until the pointer
                   * moves: the list is capped at 25, so the bar is what says
                   * there is more of it below the fold.
                   */
                  className={cn(
                    "m-0 max-h-[300px] list-none overflow-y-auto p-0",
                    "[scrollbar-width:thin] [scrollbar-color:var(--newt-bg-active)_transparent]",
                    "[&::-webkit-scrollbar]:w-2",
                    "[&::-webkit-scrollbar-track]:bg-transparent",
                    "[&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:border-2 [&::-webkit-scrollbar-thumb]:border-solid [&::-webkit-scrollbar-thumb]:border-transparent [&::-webkit-scrollbar-thumb]:bg-newt-bg-active [&::-webkit-scrollbar-thumb]:bg-clip-padding",
                    "[&::-webkit-scrollbar-thumb:hover]:bg-newt-text-muted [&::-webkit-scrollbar-thumb:hover]:bg-clip-padding"
                  )}
                >
                  {options.length === 0 ? (
                    <li className="px-2.5 py-2 text-sm text-newt-text-muted">
                      {EMPTY[kind]}
                    </li>
                  ) : (
                    options.map((option, index) => {
                      const picked = selected.includes(option.value)
                      const highlighted = index === active && !option.disabled
                      return (
                        <li
                          key={option.value}
                          id={optionId(index)}
                          role="option"
                          aria-selected={picked}
                          aria-disabled={option.disabled || undefined}
                          onMouseEnter={() => {
                            if (!option.disabled) setActive(index)
                          }}
                          onClick={() => toggle(index)}
                          className={cn(
                            "flex cursor-pointer items-center gap-2.5 rounded-sm px-2.5 py-2 text-sm text-newt-text-secondary",
                            highlighted && "bg-newt-bg-elevated",
                            picked && "text-newt-text-primary",
                            option.disabled &&
                              "cursor-not-allowed bg-transparent text-newt-text-muted opacity-50"
                          )}
                        >
                          {option.icon ? (
                            <span
                              aria-hidden="true"
                              className="flex h-6 w-6 shrink-0 items-center justify-center"
                            >
                              {option.icon}
                            </span>
                          ) : null}

                          <span
                            className="min-w-0 flex-1 truncate font-medium"
                            style={
                              option.color ? { color: option.color } : undefined
                            }
                          >
                            {option.label}
                          </span>

                          {option.count === undefined ? null : (
                            <Headcount count={option.count} />
                          )}

                          {option.badge === undefined ? null : (
                            <span className="shrink-0 rounded-sm bg-newt-brand px-1 py-0.5 text-[10px] font-bold uppercase leading-none text-white">
                              {option.badge}
                            </span>
                          )}

                          {/*
                           * A box rather than a tick, and only when more than
                           * one may be chosen: the shape says whether picking
                           * this one un-picks the last.
                           */}
                          {multiple ? (
                            <span
                              aria-hidden="true"
                              data-checked={picked || undefined}
                              className={cn(
                                "flex h-5 w-5 shrink-0 items-center justify-center rounded-sm border-2 border-newt-text-muted",
                                "data-[checked]:border-newt-brand data-[checked]:bg-newt-brand"
                              )}
                            >
                              {picked ? (
                                <svg
                                  viewBox="0 0 16 16"
                                  className="h-3.5 w-3.5"
                                >
                                  <path
                                    fill="#fff"
                                    d="M6.2 11.6 3.1 8.5a1 1 0 0 1 1.4-1.4l1.7 1.7 4.3-4.3a1 1 0 1 1 1.4 1.4l-5 5a1 1 0 0 1-1.4 0Z"
                                  />
                                </svg>
                              ) : null}
                            </span>
                          ) : null}
                        </li>
                      )
                    })
                  )}
                </ul>
              </div>,
              document.body
            )
          : null}
      </div>
    )
  }
)
EntitySelect.displayName = "EntitySelect"

export { EntitySelect }
<script setup lang="ts">
import type { CSSProperties, HTMLAttributes } from "vue"
import { computed, nextTick, onBeforeUnmount, ref, useId, watch } from "vue"

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

import {
  ENTITY_SELECT_MAX,
  entitySelectEmpty,
  type EntityKind,
  type EntityOption,
} from "."

/*
 * The picker for things the server already knows about: its members, its
 * roles, its channels, or all of them at once.
 *
 * It is a separate control from the string select rather than a mode of it,
 * because the rows are not strings. A member arrives with a face, a role with
 * a colour and a headcount, a channel with the glyph that says what kind of
 * channel it is — and when more than one can be chosen, the choices come back
 * out of the list and sit in the control as chips, which a string select
 * never does.
 */
const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes["class"]
    options: readonly EntityOption[]
    /** Which of the four this is. Only the empty-list wording depends on it. */
    kind?: EntityKind
    /** How many may be chosen. More than one turns the control into chips. */
    max?: number
    placeholder?: string
    disabled?: boolean
    /** Accessible name of the trigger and the listbox. */
    label?: string
  }>(),
  {
    kind: "user",
    max: 1,
    placeholder: "Make a selection",
    disabled: false,
    label: "Select an option",
  }
)

const selected = defineModel<readonly string[]>({ default: () => [] })

/* The cap is applied here: the keyboard and the option ids count rows, and a
 * row the platform would reject should not be one of them. */
const options = computed(() => props.options.slice(0, ENTITY_SELECT_MAX))
const limit = computed(() => Math.min(props.max, ENTITY_SELECT_MAX))
const multiple = computed(() => limit.value > 1)

const id = useId()
const listboxId = `${id}-listbox`
const optionId = (index: number) => `${id}-option-${index}`

const triggerRef = ref<HTMLButtonElement | null>(null)
const panelRef = ref<HTMLDivElement | null>(null)

const open = ref(false)
const active = ref(-1)
const box = ref<CSSProperties | null>(null)

const chosen = computed(() =>
  options.value.filter((option) => selected.value.includes(option.value))
)

const selectable = (index: number) => {
  const option = options.value[index]
  return Boolean(option) && !option?.disabled
}

const firstSelectable = () =>
  options.value.findIndex((option) => !option.disabled)

const lastSelectable = () => {
  for (let i = options.value.length - 1; i >= 0; i--) {
    if (!options.value[i]?.disabled) return i
  }
  return -1
}

const step = (from: number, direction: 1 | -1) => {
  const count = options.value.length
  for (let i = 1; i <= count; i++) {
    const index = (from + direction * i + count * i) % count
    if (selectable(index)) return index
  }
  return from
}

/*
 * The panel is teleported to the body so an `overflow: hidden` ancestor cannot
 * clip it, which means it is positioned from measured coordinates and flipped
 * above the control when the space below runs out.
 */
function measure() {
  const trigger = triggerRef.value
  if (!trigger) return
  const rect = trigger.getBoundingClientRect()
  const below = window.innerHeight - rect.bottom
  const height = panelRef.value?.offsetHeight ?? 0
  box.value =
    height > below && rect.top > below
      ? {
          left: `${rect.left}px`,
          width: `${rect.width}px`,
          bottom: `${window.innerHeight - rect.top + 4}px`,
        }
      : {
          left: `${rect.left}px`,
          width: `${rect.width}px`,
          top: `${rect.bottom + 4}px`,
        }
}

/*
 * `fixed` from the first frame, before there is anything to be fixed at: a
 * panel that started in the document flow would sit at the end of the body,
 * and scrolling its first row into view would drag the whole page down to it.
 * Hidden until measured, so that frame is never seen.
 */
const panelStyle = computed<CSSProperties>(() => ({
  position: "fixed",
  zIndex: 1002,
  visibility: box.value === null ? "hidden" : undefined,
  ...box.value,
}))

function onPointerDown(event: MouseEvent) {
  const target = event.target
  if (!(target instanceof Node)) return
  if (triggerRef.value?.contains(target) || panelRef.value?.contains(target)) {
    return
  }
  open.value = false
}

const update = () => measure()

watch(open, async (isOpen) => {
  if (isOpen) {
    await nextTick()
    measure()
    /* Capture phase: a nested scroller never fires a window scroll event. */
    document.addEventListener("scroll", update, {
      capture: true,
      passive: true,
    })
    window.addEventListener("resize", update)
    document.addEventListener("mousedown", onPointerDown)
    return
  }
  document.removeEventListener("scroll", update, { capture: true })
  window.removeEventListener("resize", update)
  document.removeEventListener("mousedown", onPointerDown)
})

watch(active, async (index) => {
  if (index < 0) return
  await nextTick()
  panelRef.value
    ?.querySelector(`#${CSS.escape(optionId(index))}`)
    ?.scrollIntoView({ block: "nearest" })
})

onBeforeUnmount(() => {
  document.removeEventListener("scroll", update, { capture: true })
  window.removeEventListener("resize", update)
  document.removeEventListener("mousedown", onPointerDown)
})

function openMenu() {
  if (props.disabled || open.value) return
  open.value = true
  const current = options.value.findIndex((option) =>
    selected.value.includes(option.value)
  )
  active.value =
    current >= 0 && selectable(current) ? current : firstSelectable()
}

function closeMenu() {
  open.value = false
  active.value = -1
}

function toggle(index: number) {
  const option = options.value[index]
  if (!option || option.disabled) return
  if (!multiple.value) {
    selected.value = [option.value]
    closeMenu()
    triggerRef.value?.focus()
    return
  }
  /*
   * At the limit the list stays open and stops taking more, rather than
   * silently dropping the oldest choice: the reader picked those, and a
   * control that quietly un-picks one is a control that lies.
   */
  const already = selected.value.includes(option.value)
  if (!already && selected.value.length >= limit.value) return
  selected.value = already
    ? selected.value.filter((each) => each !== option.value)
    : [...selected.value, option.value]
}

function remove(entity: string) {
  selected.value = selected.value.filter((each) => each !== entity)
}

function onKeydown(event: KeyboardEvent) {
  switch (event.key) {
    case "ArrowDown":
    case "ArrowUp": {
      event.preventDefault()
      if (!open.value) {
        openMenu()
        return
      }
      active.value = step(active.value, event.key === "ArrowDown" ? 1 : -1)
      return
    }
    case "Home":
      if (!open.value) return
      event.preventDefault()
      active.value = firstSelectable()
      return
    case "End":
      if (!open.value) return
      event.preventDefault()
      active.value = lastSelectable()
      return
    case "Enter":
    case " ": {
      event.preventDefault()
      if (!open.value) {
        openMenu()
        return
      }
      if (active.value >= 0) toggle(active.value)
      return
    }
    case "Escape":
      if (!open.value) return
      event.preventDefault()
      closeMenu()
      triggerRef.value?.focus()
      return
    case "Backspace": {
      /* The chip nearest the caret goes first, which is the last one. */
      if (!multiple.value || open.value || chosen.value.length === 0) return
      event.preventDefault()
      const last = chosen.value[chosen.value.length - 1]
      if (last) remove(last.value)
      return
    }
    default:
  }
}

function onChipMouseDown(event: MouseEvent, entity: string) {
  event.preventDefault()
  event.stopPropagation()
  remove(entity)
}
</script>

<template>
  <div :class="cn('w-full max-w-[420px]', props.class)">
    <button
      ref="triggerRef"
      type="button"
      role="combobox"
      aria-haspopup="listbox"
      :aria-expanded="open"
      :aria-controls="open ? listboxId : undefined"
      :aria-activedescendant="
        open && active >= 0 ? optionId(active) : undefined
      "
      :aria-label="props.label"
      :disabled="props.disabled"
      :class="
        cn(
          'flex w-full items-center gap-2 rounded-md border border-newt-border bg-newt-bg-base px-3 py-2 text-start text-sm text-newt-text-secondary',
          'transition-colors duration-fast ease-newt hover:border-newt-text-muted',
          'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-newt-text-link',
          open && 'border-newt-text-link',
          props.disabled && 'cursor-not-allowed opacity-50'
        )
      "
      @click="open ? closeMenu() : openMenu()"
      @keydown="onKeydown"
    >
      <span class="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
        <span v-if="chosen.length === 0" class="text-newt-text-muted">
          {{ props.placeholder }}
        </span>

        <!--
          A chip is not a button: it sits inside one, and nesting two would be
          markup a keyboard cannot untangle. Its cross is decorative, and
          Backspace is the way in from the keyboard.
        -->
        <template v-else-if="multiple">
          <span
            v-for="option of chosen"
            :key="option.value"
            class="flex items-center gap-1.5 rounded-sm bg-newt-bg-elevated px-1.5 py-1 text-xs text-newt-text-primary"
            @mousedown="onChipMouseDown($event, option.value)"
          >
            <span
              v-if="option.icon"
              aria-hidden="true"
              class="flex h-4 w-4 shrink-0"
            >
              <component :is="option.icon" />
            </span>
            <span :style="option.color ? { color: option.color } : undefined">
              {{ option.label }}
            </span>
            <svg viewBox="0 0 24 24" aria-hidden="true" class="h-3 w-3">
              <path
                fill="currentColor"
                d="M7.05 5.64a1 1 0 0 1 1.41 0L12 9.17l3.54-3.53a1 1 0 1 1 1.41 1.41L13.41 10.6l3.54 3.53a1 1 0 0 1-1.41 1.42L12 12.01l-3.54 3.54a1 1 0 0 1-1.41-1.42l3.53-3.53-3.53-3.54a1 1 0 0 1 0-1.42Z"
              />
            </svg>
          </span>
        </template>

        <span v-else class="flex min-w-0 items-center gap-2">
          <span
            v-if="chosen[0]?.icon"
            aria-hidden="true"
            class="flex h-5 w-5 shrink-0"
          >
            <component :is="chosen[0].icon" />
          </span>
          <span
            class="truncate text-newt-text-primary"
            :style="chosen[0]?.color ? { color: chosen[0].color } : undefined"
          >
            {{ chosen[0]?.label }}
          </span>
        </span>
      </span>

      <svg
        viewBox="0 0 24 24"
        aria-hidden="true"
        :class="cn('h-[18px] w-[18px] shrink-0', open && 'rotate-180')"
      >
        <path
          fill="currentColor"
          d="M5.3 9.3a1 1 0 0 1 1.4 0l5.3 5.29 5.3-5.3a1 1 0 1 1 1.4 1.42l-6 6a1 1 0 0 1-1.4 0l-6-6a1 1 0 0 1 0-1.42Z"
        />
      </svg>
    </button>

    <Teleport to="body">
      <div
        v-if="open"
        ref="panelRef"
        :style="panelStyle"
        class="rounded-md border border-newt-border bg-newt-bg-floating p-1.5 shadow-elevation-high"
        @mousedown.prevent
      >
        <!--
          The scrollbar shows rather than hiding until the pointer moves: the
          list is capped at 25, so the bar is what says there is more of it
          below the fold.
        -->
        <ul
          :id="listboxId"
          role="listbox"
          :aria-label="props.label"
          :aria-multiselectable="multiple || undefined"
          :class="
            cn(
              'm-0 max-h-[300px] list-none overflow-y-auto p-0',
              '[scrollbar-width:thin] [scrollbar-color:var(--newt-bg-active)_transparent]',
              '[&::-webkit-scrollbar]:w-2',
              '[&::-webkit-scrollbar-track]:bg-transparent',
              '[&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:border-2 [&::-webkit-scrollbar-thumb]:border-solid [&::-webkit-scrollbar-thumb]:border-transparent [&::-webkit-scrollbar-thumb]:bg-newt-bg-active [&::-webkit-scrollbar-thumb]:bg-clip-padding',
              '[&::-webkit-scrollbar-thumb:hover]:bg-newt-text-muted [&::-webkit-scrollbar-thumb:hover]:bg-clip-padding'
            )
          "
        >
          <li
            v-if="options.length === 0"
            class="px-2.5 py-2 text-sm text-newt-text-muted"
          >
            {{ entitySelectEmpty[props.kind] }}
          </li>

          <li
            v-for="(option, index) of options"
            v-else
            :id="optionId(index)"
            :key="option.value"
            role="option"
            :aria-selected="selected.includes(option.value)"
            :aria-disabled="option.disabled || undefined"
            :class="
              cn(
                'flex cursor-pointer items-center gap-2.5 rounded-sm px-2.5 py-2 text-sm text-newt-text-secondary',
                index === active && !option.disabled && 'bg-newt-bg-elevated',
                selected.includes(option.value) && 'text-newt-text-primary',
                option.disabled &&
                  'cursor-not-allowed bg-transparent text-newt-text-muted opacity-50'
              )
            "
            @mouseenter="option.disabled ? undefined : (active = index)"
            @click="toggle(index)"
          >
            <span
              v-if="option.icon"
              aria-hidden="true"
              class="flex h-6 w-6 shrink-0 items-center justify-center"
            >
              <component :is="option.icon" />
            </span>

            <span
              class="min-w-0 flex-1 truncate font-medium"
              :style="option.color ? { color: option.color } : undefined"
            >
              {{ option.label }}
            </span>

            <!-- The headcount beside a role: a number and the glyph for people. -->
            <span
              v-if="option.count !== undefined"
              class="flex shrink-0 items-center gap-1 text-xs tabular-nums text-newt-text-muted"
            >
              <svg viewBox="0 0 24 24" aria-hidden="true" class="h-3.5 w-3.5">
                <path
                  fill="currentColor"
                  d="M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm0 2c-3.9 0-7 2-7 4.5V20h14v-1.5c0-2.5-3.1-4.5-7-4.5Z"
                />
              </svg>
              {{ option.count }}
            </span>

            <span
              v-if="option.badge"
              class="shrink-0 rounded-sm bg-newt-brand px-1 py-0.5 text-[10px] font-bold uppercase leading-none text-white"
            >
              {{ option.badge }}
            </span>

            <!--
              A box rather than a tick, and only when more than one may be
              chosen: the shape says whether picking this one un-picks the last.
            -->
            <span
              v-if="multiple"
              aria-hidden="true"
              :data-checked="selected.includes(option.value) ? true : undefined"
              :class="
                cn(
                  'flex h-5 w-5 shrink-0 items-center justify-center rounded-sm border-2 border-newt-text-muted',
                  'data-[checked]:border-newt-brand data-[checked]:bg-newt-brand'
                )
              "
            >
              <svg
                v-if="selected.includes(option.value)"
                viewBox="0 0 16 16"
                class="h-3.5 w-3.5"
              >
                <path
                  fill="#fff"
                  d="M6.2 11.6 3.1 8.5a1 1 0 0 1 1.4-1.4l1.7 1.7 4.3-4.3a1 1 0 1 1 1.4 1.4l-5 5a1 1 0 0 1-1.4 0Z"
                />
              </svg>
            </span>
          </li>
        </ul>
      </div>
    </Teleport>
  </div>
</template>
import type { Component } from "vue"

export { default as EntitySelect } from "./EntitySelect.vue"

/** What the list is drawn from. Mentionable mixes members and roles. */
export type EntityKind = "user" | "role" | "channel" | "mentionable"

export interface EntityOption {
  value: string
  label: string
  /**
   * A member's avatar, or the glyph a role or channel is known by. The Vue
   * analogue of React's node: pass a component, and the control sizes it.
   */
  icon?: Component
  /** Members carrying this role — shown beside the name, as the client does. */
  count?: number
  /** A short tag after the name. An app is the one that always has one. */
  badge?: string
  /** A role's colour, which its name takes in the list and in its chip. */
  color?: string
  disabled?: boolean
}

/**
 * What one of these may carry. The platform takes at most this many either
 * way — as the list it offers, and as the selection it accepts back.
 */
export const ENTITY_SELECT_MAX = 25

/** The line shown when the server has nothing of this kind to offer. */
export const entitySelectEmpty = {
  user: "No members to show",
  role: "No roles to show",
  channel: "No channels to show",
  mentionable: "Nothing to show",
} as const satisfies Record<EntityKind, string>

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 { EntitySelect } from "@/components/ui/entity-select"

export function Example() {
  return <EntitySelect />
}
<script setup lang="ts">
import { EntitySelect } from "@/components/ui/entity-select"
</script>

<template>
  <EntitySelect />
</template>

API reference

EntitySelect

PropType
children / default slot?React.ReactNode / Vue slot

Content rendered inside the component.

TypeReact.ReactNode / Vue slot
className / class?string

Additional classes merged with the component defaults.

Typestring

Accessibility

Preserve the native semantics of the rendered elements and pass through ARIA attributes when the surrounding context needs a label or description. Any interactive child needs a visible focus indicator, and meaning must not rely on colour alone.

Tokens

Entity Select 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-entity-select.