RichTextEditor (toolbar + slash menu + async mentions, SSR-safe via immediatelyRender:false) + RichTextViewer (generateHTML, server-renderable) + standalone Toolbar. Pure filterSlashItems/filterUsers helpers. 12/12 vitest (incl. live editor mount + bold toggle in happy-dom); tsc clean; published to Gitea.
75 lines
2.3 KiB
TypeScript
75 lines
2.3 KiB
TypeScript
import * as React from 'react';
|
|
import type { SuggestionProps, SuggestionKeyDownProps } from '@tiptap/suggestion';
|
|
import type { MentionUser } from './mention.js';
|
|
|
|
export interface MentionListHandle {
|
|
onKeyDown: (props: SuggestionKeyDownProps) => boolean;
|
|
}
|
|
|
|
/** Keyboard-navigable people picker shown while typing `@`. */
|
|
export const MentionList = React.forwardRef<MentionListHandle, SuggestionProps<MentionUser>>(
|
|
function MentionList(props, ref) {
|
|
const { items, command } = props;
|
|
const [selected, setSelected] = React.useState(0);
|
|
|
|
React.useEffect(() => setSelected(0), [items]);
|
|
|
|
const select = React.useCallback(
|
|
(index: number) => {
|
|
const item = items[index];
|
|
if (item) command({ id: item.id, label: item.label });
|
|
},
|
|
[items, command]
|
|
);
|
|
|
|
React.useImperativeHandle(ref, () => ({
|
|
onKeyDown: ({ event }) => {
|
|
if (event.key === 'ArrowUp') {
|
|
setSelected(s => (s + items.length - 1) % items.length);
|
|
return true;
|
|
}
|
|
if (event.key === 'ArrowDown') {
|
|
setSelected(s => (s + 1) % items.length);
|
|
return true;
|
|
}
|
|
if (event.key === 'Enter') {
|
|
select(selected);
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
}));
|
|
|
|
return (
|
|
<div
|
|
role="listbox"
|
|
aria-label="Mention people"
|
|
className="min-w-48 overflow-hidden rounded-lg border border-[var(--bl-border,rgba(0,0,0,0.12))] bg-[var(--bl-surface-card,#fff)] p-1 shadow-lg"
|
|
>
|
|
{items.length === 0 ? (
|
|
<div className="px-2.5 py-1.5 text-sm text-[var(--bl-text-tertiary,#999)]">No people</div>
|
|
) : (
|
|
items.map((item, index) => (
|
|
<button
|
|
key={item.id}
|
|
type="button"
|
|
role="option"
|
|
aria-selected={index === selected}
|
|
onMouseEnter={() => setSelected(index)}
|
|
onClick={() => select(index)}
|
|
className={
|
|
'flex w-full items-center rounded-md px-2.5 py-1.5 text-left text-sm ' +
|
|
(index === selected
|
|
? 'bg-[var(--bl-accent-muted,rgba(99,102,241,0.12))]'
|
|
: 'bg-transparent')
|
|
}
|
|
>
|
|
{item.label}
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
);
|