Files
classroomio/packages/ui/src/custom/chat-textarea/chat-textarea.svelte
T
2026-04-27 11:40:38 +01:00

180 lines
5.1 KiB
Svelte

<script lang="ts">
import { cn } from '../../tools';
import { Textarea } from '../../base/textarea';
import { MentionPopover } from '../mention-popover';
import type { MentionItem } from '../mention-popover/types';
import type { Snippet } from 'svelte';
interface Props {
/** Two-way bound text value (includes raw mention syntax like `@[Title](type:id)`). */
value: string;
/** Forwarded textarea element ref for parent-controlled focus. */
ref?: HTMLTextAreaElement | null;
/** Items available for @ mention. */
mentionItems?: MentionItem[];
/** Fires when Enter is pressed (without Shift). */
onSubmit?: () => void;
/** Custom icon snippet for mention popover items. */
icon?: Snippet<[{ item: MentionItem }]>;
/** Custom type badge label for mention popover items. */
typeLabel?: (item: MentionItem) => string;
/** Action buttons rendered inside the textarea container (e.g. send, attach). */
actions?: Snippet;
/** Empty state text when no mention items match the query. */
emptyMessage?: string;
placeholder?: string;
disabled?: boolean;
/** Initial visible textarea row count. Defaults to 1 (single-line). */
rows?: number;
class?: string;
}
let {
value = $bindable(''),
ref = $bindable(null),
mentionItems = [],
onSubmit,
icon,
typeLabel,
actions,
emptyMessage = 'No results',
placeholder = '',
disabled = false,
rows = 1,
class: className
}: Props = $props();
let showPopover = $state(false);
let mentionQuery = $state('');
let mentionStartIndex = $state(-1);
let selectedIndex = $state(0);
const filteredItems = $derived(
mentionQuery
? mentionItems.filter((item) => item.label.toLowerCase().includes(mentionQuery.toLowerCase()))
: mentionItems
);
function detectMention() {
if (!ref) return;
const cursorPos = ref.selectionStart;
const textBeforeCursor = value.slice(0, cursorPos);
const lastAtIndex = textBeforeCursor.lastIndexOf('@');
if (lastAtIndex >= 0) {
const charBeforeAt = lastAtIndex > 0 ? textBeforeCursor[lastAtIndex - 1] : ' ';
const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1);
const hasClosingBracket = textAfterAt.includes(')');
if ((charBeforeAt === ' ' || charBeforeAt === '\n' || lastAtIndex === 0) && !hasClosingBracket) {
mentionStartIndex = lastAtIndex;
mentionQuery = textAfterAt;
selectedIndex = 0;
showPopover = true;
return;
}
}
showPopover = false;
}
function insertMention(item: MentionItem) {
const before = value.slice(0, mentionStartIndex);
const afterCursor = ref ? value.slice(ref.selectionStart) : '';
const mention = `@[${item.label}](${item.type}:${item.id}) `;
value = before + mention + afterCursor;
showPopover = false;
requestAnimationFrame(() => {
if (ref) {
ref.focus();
const newPos = before.length + mention.length;
ref.setSelectionRange(newPos, newPos);
}
});
}
function handleKeydown(event: KeyboardEvent) {
if (showPopover && filteredItems.length > 0) {
if (event.key === 'ArrowDown') {
event.preventDefault();
selectedIndex = (selectedIndex + 1) % filteredItems.length;
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
selectedIndex = (selectedIndex - 1 + filteredItems.length) % filteredItems.length;
return;
}
if (event.key === 'Enter') {
event.preventDefault();
insertMention(filteredItems[selectedIndex]);
return;
}
if (event.key === 'Escape') {
event.preventDefault();
showPopover = false;
return;
}
}
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
onSubmit?.();
}
}
</script>
<div data-slot="chat-textarea" class={cn('ui:relative', className)}>
{#if showPopover && mentionItems.length > 0}
<div class="ui:absolute ui:bottom-full ui:left-0 ui:z-50 ui:mb-1">
{#if icon}
<MentionPopover
items={mentionItems}
query={mentionQuery}
{selectedIndex}
onSelect={insertMention}
{icon}
{typeLabel}
{emptyMessage}
/>
{:else}
<MentionPopover
items={mentionItems}
query={mentionQuery}
{selectedIndex}
onSelect={insertMention}
{typeLabel}
{emptyMessage}
/>
{/if}
</div>
{/if}
<div
class="ui:border-input ui:focus-within:border-ring ui:focus-within:ring-ring/50 ui:rounded-lg ui:border ui:shadow-xs ui:transition-[color,box-shadow] ui:focus-within:ring-[3px]"
>
<Textarea
bind:ref
bind:value
oninput={detectMention}
onkeydown={handleKeydown}
{placeholder}
{disabled}
{rows}
class="ui:min-h-0 ui:max-h-32 ui:resize-none ui:border-0 ui:py-2 ui:text-sm ui:shadow-none ui:focus-visible:ring-0"
/>
{#if actions}
<div class="ui:flex ui:items-center ui:gap-1 ui:px-2 ui:pb-2">
{@render actions()}
</div>
{/if}
</div>
</div>