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.
87 lines
2.7 KiB
TypeScript
87 lines
2.7 KiB
TypeScript
import * as React from 'react';
|
|
import type { SuggestionProps, SuggestionKeyDownProps } from '@tiptap/suggestion';
|
|
import type { SlashItem } from './slashMenu.js';
|
|
|
|
export interface SlashMenuListHandle {
|
|
onKeyDown: (props: SuggestionKeyDownProps) => boolean;
|
|
}
|
|
|
|
/**
|
|
* Keyboard-navigable list rendered inside the slash-menu popup. Exposes an
|
|
* imperative `onKeyDown` so the Suggestion plugin can route ↑/↓/Enter here.
|
|
*/
|
|
export const SlashMenuList = React.forwardRef<SlashMenuListHandle, SuggestionProps<SlashItem>>(
|
|
function SlashMenuList(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(item);
|
|
},
|
|
[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;
|
|
},
|
|
}));
|
|
|
|
if (items.length === 0) {
|
|
return (
|
|
<div
|
|
role="listbox"
|
|
aria-label="Slash commands"
|
|
className="min-w-56 rounded-lg border border-[var(--bl-border,rgba(0,0,0,0.12))] bg-[var(--bl-surface-card,#fff)] p-2 text-sm text-[var(--bl-text-tertiary,#999)] shadow-lg"
|
|
>
|
|
No matches
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
role="listbox"
|
|
aria-label="Slash commands"
|
|
className="min-w-56 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.map((item, index) => (
|
|
<button
|
|
key={item.title}
|
|
type="button"
|
|
role="option"
|
|
aria-selected={index === selected}
|
|
onMouseEnter={() => setSelected(index)}
|
|
onClick={() => select(index)}
|
|
className={
|
|
'flex w-full flex-col items-start 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')
|
|
}
|
|
>
|
|
<span className="font-medium text-[var(--bl-text-primary,#111)]">{item.title}</span>
|
|
<span className="text-xs text-[var(--bl-text-tertiary,#999)]">{item.description}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
);
|