feat(ui): add Button, Modal, Spinner, Toast, and Tooltip components with styles
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
- Implemented Button component with various props for customization. - Created Modal component with header, content, and footer subcomponents. - Added Spinner component for loading indicators. - Developed Toast component for displaying notifications. - Introduced Tooltip component for contextual hints with keyboard shortcuts. - Added corresponding CSS modules for styling each component. - Updated index file to export new components. - Configured TypeScript settings for the UI package.
This commit is contained in:
257
packages/shared/src/components/channel/MentionAutocomplete.tsx
Normal file
257
packages/shared/src/components/channel/MentionAutocomplete.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Mention autocomplete popup — opens above the chat input when the user
|
||||
* types `@`. Renders the server members for the current channel, with
|
||||
* arrow-key navigation and Enter/click to insert.
|
||||
*/
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useQuery } from 'convex/react';
|
||||
import { Avatar } from '@discord-clone/ui';
|
||||
import { Megaphone } from '@phosphor-icons/react';
|
||||
import { api } from '../../../../../convex/_generated/api';
|
||||
import styles from './MentionAutocomplete.module.css';
|
||||
|
||||
export type MentionItem =
|
||||
| {
|
||||
kind: 'user';
|
||||
userId: string;
|
||||
displayName: string;
|
||||
username: string;
|
||||
avatar?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'everyone';
|
||||
};
|
||||
|
||||
export interface MentionAutocompleteHandle {
|
||||
/** Move the keyboard selection. Returns true if the popup consumed the key. */
|
||||
moveSelection: (delta: number) => boolean;
|
||||
/** Commit the currently highlighted item. Returns true if something was selected. */
|
||||
commit: () => boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
channelId: string;
|
||||
query: string;
|
||||
anchorEl: HTMLElement | null;
|
||||
onSelect: (item: MentionItem) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const MAX_RESULTS = 10;
|
||||
|
||||
/**
|
||||
* Rank a candidate against the query. Lower score = better.
|
||||
* -2: startsWith match (best)
|
||||
* -1: word-boundary match
|
||||
* 0: substring match anywhere
|
||||
* null: no match
|
||||
*/
|
||||
function scoreMatch(candidate: string, query: string): number | null {
|
||||
if (!query) return 0;
|
||||
const c = candidate.toLowerCase();
|
||||
const q = query.toLowerCase();
|
||||
if (c.startsWith(q)) return -2;
|
||||
const words = c.split(/[\s_\-.:/]+/);
|
||||
for (const w of words) {
|
||||
if (w !== c && w.startsWith(q)) return -1;
|
||||
}
|
||||
return c.includes(q) ? 0 : null;
|
||||
}
|
||||
|
||||
interface ConvexMember {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
}
|
||||
|
||||
export const MentionAutocomplete = forwardRef<MentionAutocompleteHandle, Props>(
|
||||
function MentionAutocomplete({ channelId, query, anchorEl, onSelect, onClose }, ref) {
|
||||
const [selected, setSelected] = useState(0);
|
||||
const [pos, setPos] = useState<{ left: number; bottom: number; width: number } | null>(
|
||||
null,
|
||||
);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const membersRaw = useQuery(
|
||||
api.members.getChannelMembers,
|
||||
channelId ? { channelId: channelId as any } : 'skip',
|
||||
) as ConvexMember[] | undefined;
|
||||
|
||||
const items = useMemo<MentionItem[]>(() => {
|
||||
const result: MentionItem[] = [];
|
||||
if (scoreMatch('everyone', query) !== null) {
|
||||
result.push({ kind: 'everyone' });
|
||||
}
|
||||
if (membersRaw) {
|
||||
type Scored = { item: MentionItem; score: number; name: string };
|
||||
const scored: Scored[] = [];
|
||||
for (const m of membersRaw) {
|
||||
const name = m.displayName || m.username;
|
||||
const s1 = scoreMatch(name, query);
|
||||
const s2 = scoreMatch(m.username, query);
|
||||
const best =
|
||||
s1 === null && s2 === null
|
||||
? null
|
||||
: Math.min(s1 ?? Infinity, s2 ?? Infinity);
|
||||
if (best === null) continue;
|
||||
scored.push({
|
||||
item: {
|
||||
kind: 'user',
|
||||
userId: m.id,
|
||||
displayName: name,
|
||||
username: m.username,
|
||||
avatar: m.avatarUrl ?? undefined,
|
||||
},
|
||||
score: best,
|
||||
name,
|
||||
});
|
||||
}
|
||||
scored.sort((a, b) => {
|
||||
if (a.score !== b.score) return a.score - b.score;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
result.push(...scored.slice(0, MAX_RESULTS).map((s) => s.item));
|
||||
}
|
||||
return result;
|
||||
}, [membersRaw, query]);
|
||||
|
||||
// Clamp selection when the list changes.
|
||||
useEffect(() => {
|
||||
setSelected((prev) => {
|
||||
if (items.length === 0) return 0;
|
||||
if (prev >= items.length) return items.length - 1;
|
||||
return prev;
|
||||
});
|
||||
}, [items]);
|
||||
|
||||
// Close if the filter yielded nothing — but only after the query
|
||||
// has had a chance to resolve. While the query is still loading
|
||||
// (membersRaw === undefined) we keep the popup open so the user
|
||||
// doesn't see it flicker.
|
||||
useEffect(() => {
|
||||
if (membersRaw !== undefined && items.length === 0) {
|
||||
onClose();
|
||||
}
|
||||
}, [membersRaw, items.length, onClose]);
|
||||
|
||||
// Position above the anchor (the textarea).
|
||||
useLayoutEffect(() => {
|
||||
if (!anchorEl) return;
|
||||
const update = () => {
|
||||
const rect = anchorEl.getBoundingClientRect();
|
||||
setPos({
|
||||
left: rect.left,
|
||||
bottom: window.innerHeight - rect.top + 8,
|
||||
width: Math.max(280, Math.min(rect.width, 520)),
|
||||
});
|
||||
};
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
window.addEventListener('scroll', update, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', update);
|
||||
window.removeEventListener('scroll', update, true);
|
||||
};
|
||||
}, [anchorEl]);
|
||||
|
||||
// Keep the highlighted row in view.
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const el = container.querySelector<HTMLElement>(`[data-index="${selected}"]`);
|
||||
el?.scrollIntoView({ block: 'nearest' });
|
||||
}, [selected]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
moveSelection: (delta) => {
|
||||
if (items.length === 0) return false;
|
||||
setSelected((prev) => {
|
||||
const next = (prev + delta + items.length) % items.length;
|
||||
return next;
|
||||
});
|
||||
return true;
|
||||
},
|
||||
commit: () => {
|
||||
const item = items[selected];
|
||||
if (!item) return false;
|
||||
onSelect(item);
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
[items, selected, onSelect],
|
||||
);
|
||||
|
||||
if (!pos || items.length === 0) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={styles.container}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: pos.left,
|
||||
bottom: pos.bottom,
|
||||
width: pos.width,
|
||||
zIndex: 15000,
|
||||
}}
|
||||
// Prevent the contenteditable from losing focus when users
|
||||
// click into the popup.
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className={styles.header}>MEMBERS</div>
|
||||
<div className={styles.scroller}>
|
||||
{items.map((item, index) => {
|
||||
const isActive = index === selected;
|
||||
return (
|
||||
<button
|
||||
key={item.kind === 'everyone' ? 'everyone' : item.userId}
|
||||
type="button"
|
||||
data-index={index}
|
||||
className={`${styles.row} ${isActive ? styles.rowActive : ''}`}
|
||||
onMouseEnter={() => setSelected(index)}
|
||||
onClick={() => onSelect(item)}
|
||||
>
|
||||
<div className={styles.icon}>
|
||||
{item.kind === 'everyone' ? (
|
||||
<div className={styles.everyoneIcon}>
|
||||
<Megaphone size={16} weight="fill" />
|
||||
</div>
|
||||
) : (
|
||||
<Avatar
|
||||
src={item.avatar}
|
||||
fallback={item.displayName}
|
||||
size={24}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.nameWrapper}>
|
||||
<div className={styles.name}>
|
||||
{item.kind === 'everyone' ? '@everyone' : item.displayName}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.description}>
|
||||
{item.kind === 'everyone'
|
||||
? 'Notify everyone in this channel'
|
||||
: item.username}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user