Mentions
mentionsuiTextarea with trigger-character autocomplete and companion MentionTag with Twitter/X-style hover profile card popup.
Installation
$pnpm dlx uipkge-ng@latest add mentions$npx uipkge-ng@latest add mentions$yarn dlx uipkge-ng@latest add mentions$bunx uipkge-ng@latest add mentionsManifest:
https://uipkge.dev/r/angular/mentions.jsonInstalls to:src/app/components/ui/mentions/Examples
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
valueControlled textarea value (pair with `valueChange`). | string | — | optional |
options | O[] | Record<string, O[]> | [] | optional |
triggers | string[] | ['@'] | optional |
triggerPrefixes | Record<string, string> | — | optional |
prefix | string | @ | optional |
rows | number | 4 | optional |
loading | boolean | false | optional |
loadOptions | (query: string, trigger: string) | > Promise<O[]> | optional |
format | (option: O, trigger: string) | > string | optional |
placeholder | string | — | optional |
disabled | boolean | false | optional |
readOnly | boolean | false | optional |
class | string | — | optional |
valueChangeFires with the next textarea value on every input + on insert (React `onValueChange`). | EventEmitter<string> | — | optional |
mentionSelectFires when an option is committed into the text (React `onSelect`). Not named `select`: the textarea's native select event bubbles to this host and would reach `(select)` too. | EventEmitter<O> | — | optional |
searchFires whenever the active mention query changes (React `onSearch`). | EventEmitter<{ trigger: string; query: string }> | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
MentionOptioninterface MentionOption {
value: string
label: string
description?: string
avatar?: string
email?: string
handle?: string
bio?: string
joined?: string
following?: number | string
followers?: number | string
verified?: boolean
disabled?: boolean
[key: string]: unknown
}CaretRectinterface CaretRect {
top: number
left: number
height: number
}Includes
Files installed (4)
src/app/components/ui/mentions/mentions.component.ts12 kB
import { Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, Output, SimpleChanges, ViewChild, booleanAttribute, signal, ChangeDetectionStrategy, } from '@angular/core' import { cn } from '@/lib/utils' import { UiPopoverAnchorComponent, UiPopoverComponent, UiPopoverContentComponent, type PopoverDismissEvent, } from '@/ui/popover/popover.component' import { uniqueId } from '@/ui/popper/popper' import { getCaretRect, type CaretRect } from './caret-position' export { UiMentionTagComponent } from './mention-tag.component' export interface MentionOption { value: string label: string description?: string avatar?: string email?: string handle?: string bio?: string joined?: string following?: number | string followers?: number | string verified?: boolean disabled?: boolean [key: string]: unknown } /** * Angular port of UIPKGE Mentions, 1:1 with the React component: a combobox textarea that * detects a trigger character (`@` by default, or any of `triggers`) at the caret, opens a * Popover anchored at the caret with the filtered options (static list, per-trigger map, or * debounced `loadOptions`), and on Enter / Tab / click replaces trigger + query with the * formatted token. ArrowUp / ArrowDown move the highlight (skipping disabled options) and * Escape closes. Focus stays in the textarea (the popover never steals it). */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-mentions, [ui-mentions]', standalone: true, imports: [UiPopoverComponent, UiPopoverAnchorComponent, UiPopoverContentComponent], host: { 'data-uipkge': '', 'data-slot': 'mentions', '[class]': 'hostClass', }, template: ` <textarea #textarea [value]="current()" [rows]="rows" [placeholder]="placeholder" [disabled]="disabled" [readOnly]="readOnly" role="combobox" aria-autocomplete="list" aria-haspopup="listbox" [attr.aria-expanded]="open()" [attr.aria-controls]="listboxId" [attr.aria-activedescendant]="open() && filtered.length > 0 ? optionId(highlightedIndex()) : null" class="border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-16 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50" (input)="onInput($event)" (keydown)="onKeyDown($event)" (scroll)="updateAnchor()" ></textarea> <ui-popover [open]="open()" (openChange)="open.set($event)"> <div ui-popover-anchor aria-hidden="true" [style.display]="caretRect() ? null : 'none'" [style.position]="caretRect() ? 'fixed' : null" [style.top.px]="caretRect() ? caretRect()!.top + caretRect()!.height : null" [style.left.px]="caretRect()?.left ?? null" [style.width.px]="caretRect() ? 0 : null" [style.height.px]="caretRect() ? 0 : null" [style.pointer-events]="caretRect() ? 'none' : null" ></div> <ui-popover-content align="start" [sideOffset]="4" class="border-border/80 w-64 rounded-lg p-1 shadow-md" (openAutoFocus)="preventFocus($event)" > <div [id]="listboxId"> @if (totalLoading) { <div class="text-muted-foreground px-2 py-3 text-sm" role="status">Loading...</div> } @else if (filtered.length === 0) { <div class="text-muted-foreground px-2 py-3 text-sm" role="status">No matches</div> } @else { <ul class="max-h-64 overflow-auto" role="listbox" aria-label="Mentions"> @for (opt of filtered; track opt.value; let i = $index) { <li [id]="optionId(i)" role="option" [attr.aria-selected]="i === highlightedIndex()" [attr.aria-disabled]="opt.disabled || null" [class]="optionClass(opt, i)" (mouseenter)="!opt.disabled && highlightedIndex.set(i)" (mousedown)="$event.preventDefault(); !opt.disabled && insert(opt)" > @if (opt.avatar) { <img [src]="opt.avatar" alt="" class="size-6 rounded-full object-cover" /> } <div class="min-w-0 flex-1"> <div class="truncate font-medium">{{ opt.label }}</div> @if (opt.description || opt.email) { <div class="text-muted-foreground truncate text-xs">{{ opt.description || opt.email }}</div> } </div> </li> } </ul> } </div> </ui-popover-content> </ui-popover> `, }) export class UiMentionsComponent<O extends MentionOption = MentionOption> implements OnChanges, OnDestroy { /** Controlled textarea value (pair with `valueChange`). */ @Input() value = '' @Input() options: O[] | Record<string, O[]> = [] @Input() triggers: string[] = ['@'] @Input() triggerPrefixes?: Record<string, string> @Input() prefix = '@' @Input() rows = 4 @Input({ transform: booleanAttribute }) loading = false @Input() loadOptions?: (query: string, trigger: string) => Promise<O[]> @Input() format?: (option: O, trigger: string) => string @Input() placeholder = '' @Input({ transform: booleanAttribute }) disabled = false @Input({ transform: booleanAttribute }) readOnly = false @Input('class') className?: string /** Fires with the next textarea value on every input + on insert (React `onValueChange`). */ @Output() valueChange = new EventEmitter<string>() /** * Fires when an option is committed into the text (React `onSelect`). Not named `select`: * the textarea's native select event bubbles to this host and would reach `(select)` too. */ @Output() mentionSelect = new EventEmitter<O>() /** Fires whenever the active mention query changes (React `onSearch`). */ @Output() search = new EventEmitter<{ trigger: string; query: string }>() @ViewChild('textarea', { static: true }) private textareaRef!: ElementRef<HTMLTextAreaElement> readonly open = signal(false) readonly activeTrigger = signal('') readonly query = signal('') readonly highlightedIndex = signal(0) readonly asyncResults = signal<O[]>([]) readonly isAsyncLoading = signal(false) readonly caretRect = signal<CaretRect | null>(null) /** Latest text: the `value` input, advanced by typing / inserts until the parent echoes it. */ readonly current = signal('') private triggerIndex = -1 private asyncToken = 0 private debounceTimer: ReturnType<typeof setTimeout> | null = null readonly listboxId = uniqueId('mentions-listbox') ngOnChanges(changes: SimpleChanges): void { if (changes['value']) this.current.set(this.value ?? '') } ngOnDestroy(): void { if (this.debounceTimer) clearTimeout(this.debounceTimer) } get hostClass(): string { return cn('block', 'relative w-full', this.className) } optionId(i: number): string { return `${this.listboxId}-opt-${i}` } private get currentOptionsList(): O[] { const options = this.options if (!options) return [] if (Array.isArray(options)) return options return options[this.activeTrigger()] ?? [] } get filtered(): O[] { if (this.loadOptions) return this.asyncResults() const source = this.currentOptionsList const query = this.query() if (!query) return source const q = query.toLowerCase() return source.filter( (o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q) || (o.email && o.email.toLowerCase().includes(q)), ) } get totalLoading(): boolean { return this.loading || this.isAsyncLoading() } optionClass(opt: O, i: number): string { return cn( 'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors', i === this.highlightedIndex() && !opt.disabled ? 'bg-accent text-accent-foreground' : '', opt.disabled ? 'cursor-not-allowed opacity-50' : '', ) } /** React: `onOpenAutoFocus={(e) => e.preventDefault()}` keeps the caret in the textarea. */ preventFocus(e: PopoverDismissEvent): void { e.preventDefault() } private moveHighlight(delta: number): void { const list = this.filtered const len = list.length if (len === 0) return let i = this.highlightedIndex() for (let n = 0; n < len; n++) { i = (i + delta + len) % len if (!list[i]?.disabled) { document.getElementById(this.optionId(i))?.scrollIntoView?.({ block: 'nearest' }) this.highlightedIndex.set(i) return } } } private findActiveMention(val: string, caret: number): { trigger: string; index: number; query: string } | null { for (let i = caret - 1; i >= 0; i--) { const ch = val[i]! if (this.triggers.includes(ch)) { const before = i === 0 ? '' : val[i - 1]! if (i === 0 || /\s/.test(before)) return { trigger: ch, index: i, query: val.substring(i + 1, caret) } return null } if (/\s/.test(ch)) return null } return null } updateAnchor(): void { const ta = this.textareaRef.nativeElement this.caretRect.set(getCaretRect(ta, ta.selectionStart ?? 0)) } private async runAsync(trigger: string, q: string): Promise<void> { if (!this.loadOptions) return const token = ++this.asyncToken this.isAsyncLoading.set(true) try { const results = await this.loadOptions(q, trigger) if (token === this.asyncToken) this.asyncResults.set(results) } finally { if (token === this.asyncToken) this.isAsyncLoading.set(false) } } private scheduleAsync(trigger: string, q: string): void { if (this.debounceTimer) clearTimeout(this.debounceTimer) this.debounceTimer = setTimeout(() => void this.runAsync(trigger, q), 200) } onInput(e: Event): void { const ta = e.target as HTMLTextAreaElement const next = ta.value this.current.set(next) this.valueChange.emit(next) const match = this.findActiveMention(next, ta.selectionStart ?? 0) if (match) { // Anchor at the caret before opening so the first placement is already right. this.updateAnchor() this.activeTrigger.set(match.trigger) this.triggerIndex = match.index this.query.set(match.query) this.open.set(true) this.search.emit({ trigger: match.trigger, query: match.query }) if (this.loadOptions) this.scheduleAsync(match.trigger, match.query) requestAnimationFrame(() => this.updateAnchor()) } else { this.open.set(false) } } private defaultFormat(option: O, trigger: string): string { const resolvedPrefix = this.triggerPrefixes?.[trigger] ?? trigger ?? this.prefix ?? '@' return `${resolvedPrefix}${option.value} ` } insert(option: O): void { const ta = this.textareaRef.nativeElement const caret = ta.selectionStart ?? 0 const value = this.current() const before = value.substring(0, this.triggerIndex) const after = value.substring(caret) const trigger = this.activeTrigger() const token = this.format ? this.format(option, trigger) : this.defaultFormat(option, trigger) const next = before + token + after this.current.set(next) this.valueChange.emit(next) this.mentionSelect.emit(option) this.open.set(false) requestAnimationFrame(() => { const pos = before.length + token.length ta.focus() ta.setSelectionRange(pos, pos) }) } onKeyDown(e: KeyboardEvent): void { if (!this.open()) return if (e.key === 'Escape') { e.preventDefault() this.open.set(false) return } const list = this.filtered if (list.length === 0) return if (e.key === 'ArrowDown') { e.preventDefault() this.moveHighlight(1) } else if (e.key === 'ArrowUp') { e.preventDefault() this.moveHighlight(-1) } else if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault() const opt = list[this.highlightedIndex()] if (opt && !opt.disabled) this.insert(opt) } } }src/app/components/ui/mentions/mention-tag.component.ts8.5 kB
import { AfterViewChecked, Component, ElementRef, Input, TemplateRef, booleanAttribute, inject, signal, ChangeDetectionStrategy, } from '@angular/core' import { cn } from '@/lib/utils' import { UiHoverCardComponent, UiHoverCardContentComponent, UiHoverCardTriggerComponent, } from '@/ui/hover-card/hover-card.component' import { UiAvatarComponent, UiAvatarFallbackComponent, UiAvatarImageComponent } from '@/ui/avatar/avatar.component' import { UiRenderTemplateDirective } from '@/ui/popper/popper' /** * Angular port of UIPKGE MentionTag, 1:1 with the React component: an inline chip (an * `<a>` when `href` is set, else a `<span>`) showing `trigger` + name, which reveals a * Twitter/X-style profile HoverCard (avatar, Follow toggle, name + verified badge, handle, * email, bio, joined date, following / followers) after `openDelay`. `popover=false` * renders the bare chip; `popupContent` replaces the card body. Projected content replaces * the default chip text (React `children`). */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-mention-tag, [ui-mention-tag]', standalone: true, imports: [ UiHoverCardComponent, UiHoverCardTriggerComponent, UiHoverCardContentComponent, UiAvatarComponent, UiAvatarImageComponent, UiAvatarFallbackComponent, UiRenderTemplateDirective, ], // React renders the chip itself (no wrapper element). host: { '[class]': '"contents"' }, template: ` <ng-template #chipContent> <ng-content> <span class="text-primary font-semibold">{{ trigger }}</span> <span>{{ name || handle || email }}</span> </ng-content> </ng-template> @if (popover) { <ui-hover-card [openDelay]="openDelay" [closeDelay]="closeDelay"> @if (href) { <a ui-hover-card-trigger [attr.href]="href" data-uipkge="" data-slot="mention-tag" [class]="chipClass" ><ng-container [uiRenderTemplate]="chipContent" /></a> } @else { <span ui-hover-card-trigger data-uipkge="" data-slot="mention-tag" [class]="chipClass" ><ng-container [uiRenderTemplate]="chipContent" /></span> } <ui-hover-card-content align="start" [sideOffset]="6" class="border-border/80 w-80 rounded-xl p-4 shadow-lg"> @if (popupContent) { <ng-container [uiRenderTemplate]="popupContent" /> } @else { <div class="space-y-3"> <div class="flex items-start justify-between gap-3"> <ui-avatar class="ring-border/50 size-12 ring-2"> @if (avatar) { <ui-avatar-image [src]="avatar" [alt]="name || handle || ''" /> } <ui-avatar-fallback class="text-sm font-semibold">{{ initials }}</ui-avatar-fallback> </ui-avatar> <button type="button" [class]="followClass" (click)="$event.stopPropagation(); isFollowing.set(!isFollowing())" > {{ isFollowing() ? 'Following' : 'Follow' }} </button> </div> <div> <div class="flex items-center gap-1"> <span class="text-foreground text-sm font-bold tracking-tight">{{ name || handle }}</span> @if (verified) { <span class="text-primary inline-flex" title="Verified"> <svg class="size-3.5 fill-current" viewBox="0 0 24 24" aria-hidden="true"> <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" /> </svg> </span> } </div> <p class="text-muted-foreground font-mono text-xs">{{ formattedHandle }}</p> @if (email) { <p class="text-muted-foreground mt-0.5 text-xs">{{ email }}</p> } </div> @if (bio) { <p class="text-foreground/90 text-xs leading-relaxed">{{ bio }}</p> } @if (joined || location) { <div class="text-muted-foreground flex items-center gap-4 text-xs"> @if (joined) { <div class="flex items-center gap-1"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-calendar size-3.5 opacity-70" aria-hidden="true" > <path d="M8 2v4" /> <path d="M16 2v4" /> <rect width="18" height="18" x="3" y="4" rx="2" /> <path d="M3 10h18" /> </svg> <span>Joined {{ joined }}</span> </div> } </div> } @if (following !== undefined || followers !== undefined) { <div class="border-border/50 flex items-center gap-4 border-t pt-1 text-xs"> @if (following !== undefined) { <div> <span class="text-foreground font-bold">{{ following }}</span> <span class="text-muted-foreground ml-1">Following</span> </div> } @if (followers !== undefined) { <div> <span class="text-foreground font-bold">{{ followers }}</span> <span class="text-muted-foreground ml-1">Followers</span> </div> } </div> } </div> } </ui-hover-card-content> </ui-hover-card> } @else if (href) { <a [attr.href]="href" data-uipkge="" data-slot="mention-tag" [class]="chipClass" ><ng-container [uiRenderTemplate]="chipContent" /></a> } @else { <span data-uipkge="" data-slot="mention-tag" [class]="chipClass" ><ng-container [uiRenderTemplate]="chipContent" /></span> } `, }) export class UiMentionTagComponent implements AfterViewChecked { private readonly el = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement @Input() trigger = '@' @Input() name?: string @Input() handle?: string @Input() email?: string @Input() avatar?: string @Input() bio?: string @Input() joined?: string @Input() location?: string @Input() following?: number | string @Input() followers?: number | string @Input({ transform: booleanAttribute }) verified = false @Input() href?: string @Input({ transform: booleanAttribute }) popover = true @Input() openDelay = 150 @Input() closeDelay = 100 /** Replaces the default profile card body (React `popupContent` node). */ @Input() popupContent?: TemplateRef<unknown> | null @Input('class') className?: string readonly isFollowing = signal(false) ngAfterViewChecked(): void { // Radix Slot: the chip's data-slot wins over HoverCardTrigger's (Angular host bindings would win). const chip = this.el.querySelector(':scope > ui-hover-card > [data-slot="hover-card-trigger"]') chip?.setAttribute('data-slot', 'mention-tag') } get formattedHandle(): string { if (!this.handle && !this.name) return '' const h = this.handle || this.name || '' return h.startsWith(this.trigger) ? h : `${this.trigger}${h}` } get initials(): string { return (this.name || this.handle || 'U').slice(0, 2).toUpperCase() } get chipClass(): string { return cn( 'inline-flex cursor-pointer items-center gap-0.5 rounded px-1.5 py-0.5 text-sm font-medium transition-colors select-none', 'bg-muted/70 text-foreground hover:bg-accent hover:text-accent-foreground', 'focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none', this.className, ) } get followClass(): string { return cn( 'h-8 rounded-full px-3.5 text-xs font-semibold transition-[transform,background-color] duration-150 active:scale-95', this.isFollowing() ? 'border-border text-foreground hover:bg-muted border bg-transparent' : 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs', ) } }src/app/components/ui/mentions/caret-position.ts1.8 kB
const PROPERTIES_TO_COPY = [ 'direction', 'boxSizing', 'width', 'height', 'overflowX', 'overflowY', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth', 'borderStyle', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'fontStyle', 'fontVariant', 'fontWeight', 'fontStretch', 'fontSize', 'fontSizeAdjust', 'lineHeight', 'fontFamily', 'textAlign', 'textTransform', 'textIndent', 'textDecoration', 'letterSpacing', 'wordSpacing', 'tabSize', 'whiteSpace', 'wordBreak', 'wordWrap', ] as const export interface CaretRect { top: number left: number height: number } export function getCaretRect(textarea: HTMLTextAreaElement, position: number): CaretRect { const div = document.createElement('div') document.body.appendChild(div) const style = div.style const computed = window.getComputedStyle(textarea) style.position = 'absolute' style.visibility = 'hidden' style.whiteSpace = 'pre-wrap' style.wordWrap = 'break-word' style.top = '0' style.left = '0' for (const prop of PROPERTIES_TO_COPY) { ;(style as any)[prop] = (computed as any)[prop] } style.overflow = 'hidden' const text = textarea.value.substring(0, position) div.textContent = text const span = document.createElement('span') span.textContent = textarea.value.substring(position) || '.' div.appendChild(span) const spanRect = span.getBoundingClientRect() const divRect = div.getBoundingClientRect() const taRect = textarea.getBoundingClientRect() const result: CaretRect = { top: taRect.top + (spanRect.top - divRect.top) - textarea.scrollTop, left: taRect.left + (spanRect.left - divRect.left) - textarea.scrollLeft, height: spanRect.height, } document.body.removeChild(div) return result }src/app/components/ui/mentions/index.ts0.2 kB
export { UiMentionsComponent, type MentionOption } from './mentions.component' export { UiMentionTagComponent } from './mention-tag.component' export * from './caret-position'
Raw manifest:https://uipkge.dev/r/angular/mentions.json