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

- 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:
Bryan1029384756
2026-04-14 09:02:14 -05:00
parent 9ef839938e
commit b7a4cf4ce8
376 changed files with 52619 additions and 167641 deletions

18
packages/ui/package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "@discord-clone/ui",
"private": true,
"version": "1.0.0",
"type": "module",
"main": "src/index.ts",
"dependencies": {
"@phosphor-icons/react": "^2.1.7",
"clsx": "^2.1.1",
"framer-motion": "^11.0.0",
"@floating-ui/react": "^0.26.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"typescript": "^5.7.0"
}
}

View File

@@ -0,0 +1,51 @@
.wrapper {
position: relative;
display: inline-flex;
flex-shrink: 0;
}
.image {
border-radius: 50%;
object-fit: cover;
width: 100%;
height: 100%;
}
.fallback {
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
color: #fff;
}
.statusBadge {
position: absolute;
border-radius: 50%;
border-style: solid;
/* The badge border matches whatever surface the avatar sits on
so it reads as a cut-out from the background. Default targets
the sidebar surface (`--background-secondary`), but any parent
can override via the `--avatar-status-border` custom property
— see MobileYouPage / MobileMemberProfileSheet, where the
profile sits on the darker `--background-primary` surface. */
border-color: var(--avatar-status-border, var(--background-secondary, #2b2d31));
box-sizing: content-box;
}
.online {
background: var(--status-online, #23a55a);
}
.idle {
background: var(--status-idle, #f0b232);
}
.dnd {
background: var(--status-dnd, #f23f43);
}
.offline {
background: var(--status-offline, #80848e);
}

View File

@@ -0,0 +1,93 @@
import clsx from 'clsx';
import type { CSSProperties } from 'react';
import styles from './Avatar.module.css';
export interface AvatarProps {
src?: string | null;
alt?: string;
size?: number;
fallback?: string;
status?: 'online' | 'idle' | 'dnd' | 'offline';
className?: string;
style?: CSSProperties;
onClick?: () => void;
}
function getInitials(name: string): string {
return name
.split(/\s+/)
.map((w) => w[0])
.join('')
.toUpperCase()
.slice(0, 2);
}
function stringToColor(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
const hue = Math.abs(hash % 360);
return `hsl(${hue}, 60%, 50%)`;
}
function getStatusGeometry(avatarSize: number): { dotSize: number; borderWidth: number; offset: number } {
if (avatarSize <= 16) return { dotSize: 8, borderWidth: 0, offset: -1 };
if (avatarSize <= 20) return { dotSize: 8, borderWidth: 0, offset: -1 };
if (avatarSize <= 24) return { dotSize: 10, borderWidth: 2, offset: -2 };
if (avatarSize <= 32) return { dotSize: 10, borderWidth: 3, offset: -3 };
if (avatarSize <= 36) return { dotSize: 10, borderWidth: 3, offset: -3 };
if (avatarSize <= 40) return { dotSize: 12, borderWidth: 3, offset: -3 };
if (avatarSize <= 48) return { dotSize: 14, borderWidth: 3, offset: -3 };
if (avatarSize <= 56) return { dotSize: 16, borderWidth: 3, offset: -3 };
if (avatarSize <= 80) return { dotSize: 16, borderWidth: 6, offset: -4 };
return { dotSize: 24, borderWidth: 8, offset: -6 };
}
export function Avatar({ src, alt = '', size = 40, fallback, status, className, style, onClick }: AvatarProps) {
const initials = fallback ? getInitials(fallback) : alt ? getInitials(alt) : '?';
const bgColor = stringToColor(fallback || alt || '?');
const geo = getStatusGeometry(size);
return (
<div
className={clsx(styles.wrapper, className)}
style={{
width: size,
height: size,
cursor: onClick ? 'pointer' : undefined,
...style,
}}
onClick={onClick}
>
{src ? (
<img src={src} alt={alt} className={styles.image} />
) : (
<div
className={styles.fallback}
style={{
width: size,
height: size,
backgroundColor: bgColor,
fontSize: size * 0.4,
userSelect: 'none',
}}
>
{initials}
</div>
)}
{status && (
<div
className={clsx(styles.statusBadge, styles[status])}
style={{
width: geo.dotSize,
height: geo.dotSize,
borderWidth: geo.borderWidth,
bottom: geo.offset,
right: geo.offset,
}}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,109 @@
.overlay {
position: fixed;
inset: 0;
z-index: var(--z-index-modal, 10000);
display: flex;
flex-direction: column;
justify-content: flex-end;
pointer-events: none;
}
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
pointer-events: auto;
}
.sheet {
position: relative;
width: 100%;
/* Fill 95% of the viewport by default so drawers feel like full-screen
sheets on mobile (matches Fluxer). Uses svh so the mobile browser
chrome (address bar) doesn't cause the sheet to overflow. */
height: 95svh;
max-height: 95svh;
/* Use the sidebar surface colour so the drawer sits one step darker
than the chat area, making the rounded action items pop against
the sheet background. */
background-color: var(--background-secondary);
border-top-left-radius: 20px;
border-top-right-radius: 20px;
box-shadow: 0 -12px 32px rgba(0, 0, 0, 0.4);
display: flex;
flex-direction: column;
overflow: hidden;
pointer-events: auto;
padding-bottom: env(safe-area-inset-bottom, 0);
touch-action: pan-y;
}
.handleRow {
display: flex;
align-items: center;
justify-content: center;
padding: 8px 0 4px;
flex-shrink: 0;
cursor: grab;
}
.handle {
width: 40px;
height: 4px;
border-radius: 2px;
background-color: var(--text-tertiary, rgba(255, 255, 255, 0.25));
opacity: 0.6;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 20px 16px;
border-bottom: 1px solid var(--user-area-divider-color, rgba(255, 255, 255, 0.06));
flex-shrink: 0;
}
.title {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
}
.closeButton {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
flex-shrink: 0;
background: transparent;
border: none;
color: var(--text-secondary);
border-radius: 50%;
cursor: pointer;
transition: background-color 0.15s, color 0.15s;
}
.closeButton:hover {
background-color: var(--background-modifier-hover, rgba(255, 255, 255, 0.04));
color: var(--text-primary);
}
.body {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}

View File

@@ -0,0 +1,258 @@
import { useEffect, useCallback, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { AnimatePresence, motion } from 'framer-motion';
import { X } from '@phosphor-icons/react';
import clsx from 'clsx';
import styles from './BottomSheet.module.css';
/**
* BottomSheet — a mobile-style slide-up drawer. Portal-based, backdrop
* + Escape + click-outside close, drag handle, safe-area-inset aware.
*
* Usage:
* <BottomSheet isOpen={open} onClose={close} title="Search">
* ...body...
* </BottomSheet>
*
* By default the sheet snaps to 95svh and scrolls its body internally.
* Callers that need a partial-height drawer can pass `initialHeightSvh`
* (e.g. 50 for half-screen) and `expandable` to allow the user to
* drag the handle UP to expand the sheet to full size.
*
* Callers that need a custom header layout can pass `disableDefaultHeader`
* and render their own header as the first child.
*/
export interface BottomSheetProps {
isOpen: boolean;
onClose: () => void;
/** Title shown in the default header. Ignored when disableDefaultHeader is true. */
title?: string;
/** Render the title-X header strip. Default true. */
disableDefaultHeader?: boolean;
/** Show the top drag handle strip. Default true. */
showHandle?: boolean;
/** Close when the backdrop or drag handle is clicked. Default true. */
dismissible?: boolean;
/**
* Whether the sheet can be dragged at all (to close or to expand).
* When false, the sheet is a pure open/closed component: no drag
* gestures are attached, no intermediate snap points exist, and
* the only ways to dismiss it are the backdrop click, the Escape
* key, or the owner calling `onClose` explicitly. Default true.
*/
draggable?: boolean;
/** Optional extra class for the sheet container. */
className?: string;
/**
* Initial visible portion of the viewport in svh units (095).
* Default 95 (the full sheet shown). When set lower (e.g. 50), the
* sheet animates in to that partial height. The user can still
* drag down to dismiss; pair with `expandable` to also let them
* drag UP to grow it.
*/
initialHeightSvh?: number;
/**
* When true and `initialHeightSvh` is less than 95, the user can
* drag the sheet UP from its initial position to the fully open
* (95svh) snap point. Default false.
*/
expandable?: boolean;
/**
* Optional z-index override for the backdrop + sheet overlay.
* Needed when the sheet is opened on top of another portaled
* surface that already claims a high z-index (e.g. the
* ImageLightbox backdrop at 16000). Leave unset to inherit the
* global `--z-index-modal` token.
*/
zIndex?: number;
children: ReactNode;
}
export function BottomSheet({
isOpen,
onClose,
title,
disableDefaultHeader = false,
showHandle = true,
dismissible = true,
draggable = true,
className,
initialHeightSvh,
expandable = false,
zIndex,
children,
}: BottomSheetProps) {
const handleEscape = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape' && dismissible) onClose();
},
[onClose, dismissible],
);
useEffect(() => {
if (!isOpen) return;
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, handleEscape]);
// Lock body scroll while the sheet is open — keeps the underlying page
// from scrolling behind the drawer on mobile.
useEffect(() => {
if (!isOpen) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = prev;
};
}, [isOpen]);
// ── Snap-point logic ────────────────────────────────────────────────
// Track the live viewport height in pixels so we can convert the
// svh-based snap props into px values for Framer Motion's drag.
// We resize-listen so rotating the device or showing/hiding the
// mobile browser chrome doesn't leave the sheet stuck at a stale
// pixel snap.
const [vh, setVh] = useState(() =>
typeof window !== 'undefined' ? window.innerHeight : 0,
);
useEffect(() => {
const onResize = () => setVh(window.innerHeight);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
// The sheet's CSS height is 95svh. translate-Y of 0 means it's
// fully open (95svh visible). Translating DOWN by N pixels hides
// N pixels worth of the sheet's top portion off the bottom of the
// viewport.
//
// snapInitialPx — translation needed to show only `initialHeightSvh`
// of viewport height
// snapFullPx — 0 (sheet fully open)
// snapClosedPx — vh (sheet fully off-screen)
const partial = initialHeightSvh !== undefined && initialHeightSvh < 95;
const snapInitialPx = partial ? Math.max(0, vh * (1 - (initialHeightSvh as number) / 95)) : 0;
const snapFullPx = 0;
const snapClosedPx = vh;
// Current snap state — only matters when the sheet is partial AND
// expandable, since otherwise there's only one snap target.
type SnapName = 'initial' | 'full';
const [snap, setSnap] = useState<SnapName>('initial');
// Reset to the initial snap whenever the sheet (re-)opens so a
// previously-expanded sheet doesn't come back already expanded.
useEffect(() => {
if (isOpen) setSnap('initial');
}, [isOpen]);
const targetY = snap === 'full' ? snapFullPx : snapInitialPx;
return createPortal(
<AnimatePresence>
{isOpen && (
<div
className={styles.overlay}
style={zIndex !== undefined ? { zIndex } : undefined}
>
{/* Backdrop */}
<motion.div
className={styles.backdrop}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
onClick={dismissible ? onClose : undefined}
/>
{/* Sheet surface */}
<motion.div
className={clsx(styles.sheet, className)}
initial={{ y: '100%' }}
animate={{ y: targetY }}
exit={{ y: '100%' }}
transition={{ type: 'spring', stiffness: 400, damping: 36 }}
onClick={(e) => e.stopPropagation()}
drag={dismissible && draggable ? 'y' : false}
dragConstraints={{
// Allow dragging UP to the fully-open snap when
// the sheet is partial and expandable; otherwise
// pin top at the initial position.
top: partial && expandable ? snapFullPx - targetY : 0,
bottom: snapClosedPx - targetY,
}}
dragElastic={{ top: partial && expandable ? 0.05 : 0, bottom: 0.2 }}
onDragEnd={(_, info) => {
const finalY = targetY + info.offset.y;
// Velocity-driven flicks first — they win over
// position-based heuristics so a fast swipe
// always does the obvious thing.
if (info.velocity.y > 600) {
// Strong downward flick → close.
onClose();
return;
}
if (partial && expandable && info.velocity.y < -600) {
// Strong upward flick → expand to full.
setSnap('full');
return;
}
// Position-based snap fallback.
if (!partial) {
// Original behavior — close if dragged
// down past the threshold, otherwise
// spring back to fully open.
if (info.offset.y > 120) onClose();
return;
}
// Partial sheet position-based snap:
// - past the initial position by half its
// distance to closed → close
// - between snaps → snap to nearest
// - above the initial position by 1/3 the
// distance to full → expand
const closeThreshold = snapInitialPx + (snapClosedPx - snapInitialPx) * 0.4;
const expandThreshold = snapInitialPx - snapInitialPx * 0.33;
if (finalY > closeThreshold) {
onClose();
return;
}
if (expandable && finalY < expandThreshold) {
setSnap('full');
return;
}
setSnap('initial');
}}
>
{showHandle && (
<div className={styles.handleRow}>
<div className={styles.handle} />
</div>
)}
{!disableDefaultHeader && title && (
<div className={styles.header}>
<h2 className={styles.title}>{title}</h2>
<button
type="button"
className={styles.closeButton}
onClick={onClose}
aria-label="Close"
>
<X size={20} weight="bold" />
</button>
</div>
)}
<div className={styles.body}>{children}</div>
</motion.div>
</div>
)}
</AnimatePresence>,
document.body,
);
}

View File

@@ -0,0 +1,270 @@
.button {
display: flex;
align-items: center;
justify-content: center;
padding: 10px 16px;
font-size: 14px;
font-weight: 600;
line-height: 20px;
height: 44px;
min-height: 44px;
min-width: 96px;
border-radius: 8px;
border: none;
cursor: pointer;
appearance: none;
text-decoration: none;
position: relative;
overflow: hidden;
transition:
background-color 0.15s ease,
color 0.15s ease,
border-color 0.15s ease;
}
.button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.button.small {
height: 40px;
min-height: 40px;
min-width: 60px;
padding: 8px 12px;
}
.button.compact {
height: 32px;
min-height: 32px;
min-width: 60px;
padding: 6px 12px;
}
.button.superCompact {
height: 24px;
min-height: 24px;
min-width: 0;
padding: 4px;
font-size: 12px;
line-height: 16px;
border-radius: 6px;
}
.button.fitContent {
min-width: 0;
padding: 10px 16px;
}
.button.superCompact.fitContent {
padding: 4px 8px;
}
.button.fitContainer {
width: 100%;
}
.button.square {
width: 44px;
min-width: 44px;
padding: 10px;
}
.button.square.small {
width: 40px;
min-width: 40px;
padding: 8px;
}
.button.square.compact {
width: 32px;
min-width: 32px;
padding: 6px;
}
/* ── Variant: Primary ── */
.button.primary {
background-color: var(--brand-primary);
color: var(--brand-primary-fill);
}
.button.primary:hover:not(:disabled) {
background-color: var(--brand-secondary);
}
.button.primary:active:not(:disabled) {
background-color: color-mix(in srgb, var(--brand-primary) 90%, #000);
}
/* ── Variant: Secondary ── */
.button.secondary {
background-color: var(--background-tertiary);
color: var(--button-secondary-text);
}
:global(.theme-light) .button.secondary {
background-color: var(--background-modifier-hover);
color: var(--button-ghost-text);
}
.button.secondary:hover:not(:disabled) {
background-color: color-mix(in srgb, var(--background-tertiary), #fff 4%);
color: var(--button-secondary-active-text);
}
:global(.theme-light) .button.secondary:hover:not(:disabled) {
background-color: color-mix(in srgb, var(--background-modifier-hover), #fff 4%);
color: var(--button-ghost-text);
}
/* ── Variant: Danger Primary ── */
.button.dangerPrimary {
background-color: var(--button-danger-fill);
color: var(--button-danger-text);
}
.button.dangerPrimary:hover:not(:disabled) {
background-color: var(--button-danger-active-fill);
}
.button.dangerPrimary:active:not(:disabled) {
background-color: color-mix(in srgb, var(--button-danger-fill) 85%, #000);
}
/* ── Variant: Danger Secondary ── */
.button.dangerSecondary {
background-color: color-mix(in srgb, var(--button-danger-fill) 12%, transparent);
color: var(--button-danger-outline-text);
}
.button.dangerSecondary:hover:not(:disabled) {
background-color: color-mix(in srgb, var(--button-danger-fill) 20%, transparent);
}
.button.dangerSecondary:active:not(:disabled) {
background-color: color-mix(in srgb, var(--button-danger-fill) 26%, transparent);
}
/* ── Variant: Ghost ── */
.button.ghost {
background-color: transparent;
color: var(--button-ghost-text, var(--text-primary));
}
.button.ghost:hover:not(:disabled) {
background-color: var(--background-modifier-hover);
}
.button.ghost:active:not(:disabled) {
background-color: var(--background-modifier-active);
}
/* ── Variant: Link ── */
.button.link {
background-color: transparent;
color: var(--brand-primary);
min-width: 0;
height: auto;
min-height: auto;
padding: 0;
font-weight: 500;
}
.button.link:hover:not(:disabled) {
text-decoration: underline;
}
/* ── Spinner (pulsing ellipsis) ── */
.spinner {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.spinnerInner {
display: flex;
align-items: center;
justify-content: center;
position: relative;
width: 28px;
height: auto;
}
.spinnerItem {
display: inline-block;
width: 6px;
height: 6px;
margin-right: 2px;
background-color: hsl(0, 0%, 100%);
border-radius: 4px;
opacity: 0.3;
animation: spinnerPulsingEllipsis 1.4s ease-in-out infinite;
}
:global(.theme-light) .button.secondary .spinnerItem {
background-color: #000;
}
.spinnerItem:nth-of-type(2) {
animation-delay: 0.2s;
}
.spinnerItem:nth-of-type(3) {
animation-delay: 0.4s;
}
/* ── Layout helpers ── */
.iconWrapper {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
white-space: nowrap;
}
.spinnerWrapper {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.hidden {
opacity: 0;
pointer-events: none;
}
.grid {
display: grid;
width: 100%;
height: 100%;
}
@keyframes spinnerPulsingEllipsis {
0% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.3;
transform: scale(0.8);
}
100% {
opacity: 1;
transform: scale(1);
}
}

108
packages/ui/src/Button.tsx Normal file
View File

@@ -0,0 +1,108 @@
import clsx from 'clsx';
import React from 'react';
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import styles from './Button.module.css';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'dangerSecondary' | 'ghost' | 'link';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
icon?: ReactNode;
fitContainer?: boolean;
fitContent?: boolean;
square?: boolean;
children?: ReactNode;
}
const variantClassMap: Record<string, string> = {
primary: 'primary',
secondary: 'secondary',
danger: 'dangerPrimary',
dangerSecondary: 'dangerSecondary',
ghost: 'ghost',
link: 'link',
};
const sizeClassMap: Record<string, string | undefined> = {
sm: 'compact',
md: undefined,
lg: undefined,
};
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
variant = 'primary',
size = 'md',
loading = false,
icon,
fitContainer = false,
fitContent = false,
square = false,
children,
className,
disabled,
type = 'button',
onClick,
...props
},
ref,
) => {
const variantClass = variantClassMap[variant] ?? 'primary';
const sizeClass = sizeClassMap[size];
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
if (loading) {
event.preventDefault();
return;
}
onClick?.(event);
};
return (
<button
ref={ref}
className={clsx(
styles.button,
styles[variantClass],
{
[styles.compact]: sizeClass === 'compact',
[styles.square]: square,
[styles.fitContainer]: fitContainer,
[styles.fitContent]: fitContent,
},
className,
)}
disabled={disabled || loading}
type={type}
onClick={handleClick}
tabIndex={disabled ? -1 : 0}
{...props}
>
<div className={styles.grid}>
<div className={clsx(styles.iconWrapper, { [styles.hidden]: loading })}>
{square ? (
icon
) : (
<>
{icon}
{children}
</>
)}
</div>
<div className={clsx(styles.spinnerWrapper, { [styles.hidden]: !loading })}>
<span className={styles.spinner}>
<span className={styles.spinnerInner}>
<span className={styles.spinnerItem} />
<span className={styles.spinnerItem} />
<span className={styles.spinnerItem} />
</span>
</span>
</div>
</div>
</button>
);
},
);
Button.displayName = 'Button';

View File

@@ -0,0 +1,119 @@
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
z-index: var(--z-index-modal, 10000);
}
.layer {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
z-index: var(--z-index-modal, 10000);
}
.root {
position: relative;
margin: 0 auto;
background-color: var(--background-secondary, #2b2d31);
border: 1px solid var(--background-header-secondary, #1e1f22);
border-radius: 8px;
box-shadow:
0 0 0 1px hsla(223, 7%, 20%, 0.08),
0 8px 24px -4px rgba(0, 0, 0, 0.25),
0 20px 48px -8px rgba(0, 0, 0, 0.2);
max-height: calc(100vh - 48px);
display: flex;
flex-direction: column;
overflow: hidden;
pointer-events: all;
}
/* Size variants */
.small {
width: 440px;
}
.medium {
width: 600px;
}
.large {
width: 800px;
}
/* Header */
.header {
flex: 0 0 auto;
display: flex;
align-items: center;
padding: 16px;
gap: 14px;
border-bottom: 1px solid var(--background-modifier-accent, #3f4147);
}
.headerTitle {
font-size: 1.25rem;
font-weight: 700;
color: var(--text-primary, #dbdee1);
flex: 1;
margin: 0;
line-height: 1.25;
}
.closeButton {
width: 28px;
height: 28px;
border-radius: 9999px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: var(--text-primary-muted, #949ba4);
cursor: pointer;
opacity: 0.5;
transition: opacity 0.15s ease, color 0.15s ease;
padding: 0;
flex-shrink: 0;
-webkit-tap-highlight-color: transparent;
}
.closeButton:hover {
opacity: 1;
color: var(--text-primary, #dbdee1);
}
.closeButton:active {
opacity: 0.8;
}
/* Content */
.content {
flex: 1 1 auto;
overflow-y: auto;
padding: 0 16px 16px;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
/* Footer */
.footer {
flex: 0 0 auto;
display: flex;
gap: 8px;
padding: 16px;
justify-content: flex-end;
border-top: 1px solid var(--background-modifier-accent, #3f4147);
}
/* Separator */
.separator {
height: 1px;
background: var(--background-modifier-accent, #3f4147);
}

190
packages/ui/src/Modal.tsx Normal file
View File

@@ -0,0 +1,190 @@
import { useEffect, useCallback, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { AnimatePresence, motion } from 'framer-motion';
import { X } from '@phosphor-icons/react';
import clsx from 'clsx';
import styles from './Modal.module.css';
// ---------------------------------------------------------------------------
// Subcomponent types
// ---------------------------------------------------------------------------
type ModalSize = 'small' | 'medium' | 'large';
interface ModalRootProps {
isOpen: boolean;
onClose: () => void;
size?: ModalSize;
children: ReactNode;
className?: string;
/**
* Optional z-index override for the backdrop + layer. Needed
* when the modal is opened on top of another portaled surface
* that already claims a high z-index (e.g. the ImageLightbox
* backdrop at 16000). Leave unset to inherit the global
* `--z-index-modal` token.
*/
zIndex?: number;
}
interface ModalHeaderProps {
title: string;
onClose?: () => void;
children?: ReactNode;
className?: string;
}
interface ModalContentProps {
children: ReactNode;
className?: string;
}
interface ModalFooterProps {
children: ReactNode;
className?: string;
}
// ---------------------------------------------------------------------------
// Modal.Root -- Portal + backdrop + centred layer + surface
// ---------------------------------------------------------------------------
function ModalRoot({ isOpen, onClose, size = 'small', children, className, zIndex }: ModalRootProps) {
const handleEscape = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
},
[onClose],
);
useEffect(() => {
if (!isOpen) return;
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, handleEscape]);
return createPortal(
<AnimatePresence>
{isOpen && (
<>
{/* Backdrop — purely decorative. The click-to-close
handler lives on `.layer` below because `.layer`
is a sibling that paints on top of the backdrop
(both `position: fixed; inset: 0`), so clicks
never actually reach the backdrop in practice. */}
<motion.div
className={styles.backdrop}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
style={zIndex !== undefined ? { zIndex } : undefined}
/>
{/* Centring layer — click-to-close target for empty
space around the modal body. The inner
`motion.div` calls `stopPropagation` so clicks
inside the modal content don't bubble up here. */}
<div
className={styles.layer}
style={zIndex !== undefined ? { zIndex } : undefined}
onClick={onClose}
>
<motion.div
className={clsx(styles.root, styles[size], className)}
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
transition={{ duration: 0.15 }}
onClick={(e) => e.stopPropagation()}
>
{children}
</motion.div>
</div>
</>
)}
</AnimatePresence>,
document.body,
);
}
// ---------------------------------------------------------------------------
// Modal.Header
// ---------------------------------------------------------------------------
function ModalHeader({ title, onClose, children, className }: ModalHeaderProps) {
return (
<div className={clsx(styles.header, className)}>
<h2 className={styles.headerTitle}>{title}</h2>
{children}
{onClose && (
<button
type="button"
className={styles.closeButton}
onClick={onClose}
aria-label="Close"
>
<X size={18} weight="bold" />
</button>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Modal.Content
// ---------------------------------------------------------------------------
function ModalContent({ children, className }: ModalContentProps) {
return <div className={clsx(styles.content, className)}>{children}</div>;
}
// ---------------------------------------------------------------------------
// Modal.Footer
// ---------------------------------------------------------------------------
function ModalFooter({ children, className }: ModalFooterProps) {
return <div className={clsx(styles.footer, className)}>{children}</div>;
}
// ---------------------------------------------------------------------------
// Backward-compatible default export
// ---------------------------------------------------------------------------
export interface ModalProps {
isOpen: boolean;
onClose: () => void;
children: ReactNode;
title?: string;
/** @deprecated Use `size` instead. Kept for backward compatibility. */
width?: number;
size?: ModalSize;
className?: string;
}
function sizeFromWidth(width: number | undefined): ModalSize {
if (width === undefined) return 'small';
if (width >= 800) return 'large';
if (width >= 600) return 'medium';
return 'small';
}
function ModalCompat({ isOpen, onClose, children, title, width, size, className }: ModalProps) {
const resolvedSize = size ?? sizeFromWidth(width);
return (
<ModalRoot isOpen={isOpen} onClose={onClose} size={resolvedSize} className={className}>
{title && <ModalHeader title={title} onClose={onClose} />}
<ModalContent>{children}</ModalContent>
</ModalRoot>
);
}
// ---------------------------------------------------------------------------
// Compose the public API: Modal + Modal.Root / Header / Content / Footer
// ---------------------------------------------------------------------------
export const Modal = Object.assign(ModalCompat, {
Root: ModalRoot,
Header: ModalHeader,
Content: ModalContent,
Footer: ModalFooter,
});

View File

@@ -0,0 +1,26 @@
export interface SpinnerProps {
size?: number;
color?: string;
}
export function Spinner({ size = 24, color = 'var(--text-secondary, #b5bac1)' }: SpinnerProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
style={{ animation: 'spin 0.8s linear infinite' }}
>
<circle
cx="12"
cy="12"
r="10"
stroke={color}
strokeWidth="3"
fill="none"
strokeLinecap="round"
strokeDasharray="50 30"
/>
</svg>
);
}

71
packages/ui/src/Toast.tsx Normal file
View File

@@ -0,0 +1,71 @@
import { AnimatePresence, motion } from 'framer-motion';
import type { ReactNode } from 'react';
export interface ToastData {
id: string;
message: ReactNode;
type?: 'info' | 'success' | 'error' | 'warning';
duration?: number;
}
export interface ToastProps {
toast: ToastData;
onDismiss: (id: string) => void;
}
const TYPE_COLORS: Record<string, string> = {
info: 'var(--brand-primary, #5865f2)',
success: '#23a55a',
error: '#f23f43',
warning: '#f0b232',
};
export function Toast({ toast, onDismiss }: ToastProps) {
return (
<motion.div
layout
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -20, scale: 0.95 }}
style={{
backgroundColor: 'var(--background-floating, #111214)',
color: 'var(--text-primary, #dbdee1)',
padding: '12px 16px',
borderRadius: 'var(--radius-md, 4px)',
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
display: 'flex',
alignItems: 'center',
gap: 12,
minWidth: 300,
maxWidth: 500,
borderLeft: `4px solid ${TYPE_COLORS[toast.type || 'info']}`,
cursor: 'pointer',
}}
onClick={() => onDismiss(toast.id)}
>
<div style={{ flex: 1, fontSize: '0.875rem' }}>{toast.message}</div>
</motion.div>
);
}
export function ToastContainer({ toasts, onDismiss }: { toasts: ToastData[]; onDismiss: (id: string) => void }) {
return (
<div
style={{
position: 'fixed',
bottom: 24,
right: 24,
zIndex: 'var(--z-index-toast, 50000)' as any,
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
<AnimatePresence>
{toasts.map((toast) => (
<Toast key={toast.id} toast={toast} onDismiss={onDismiss} />
))}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,54 @@
.tooltip {
background: var(--background-floating, #111214);
color: var(--text-primary, #dbdee1);
padding: 6px 10px;
border-radius: var(--radius-md, 6px);
font-size: 14px;
font-weight: 500;
max-width: 220px;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.24);
z-index: var(--z-index-tooltip, 45000);
pointer-events: none;
word-wrap: break-word;
line-height: 1.3;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.content {
text-align: center;
}
.shortcutRow {
display: flex;
align-items: center;
gap: 3px;
}
.keycap {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 18px;
padding: 0 5px;
border-radius: 3px;
background: var(--background-secondary, #202225);
color: var(--text-primary-muted, #b5bac1);
font-size: 10px;
font-weight: 700;
line-height: 1;
letter-spacing: 0.03em;
text-transform: uppercase;
box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.35);
}
.arrow {
width: 8px;
height: 8px;
background: var(--background-floating, #111214);
transform: rotate(45deg);
position: absolute;
}

254
packages/ui/src/Tooltip.tsx Normal file
View File

@@ -0,0 +1,254 @@
import { AnimatePresence, motion } from 'framer-motion';
import { type ReactNode, cloneElement, isValidElement, useRef, useState, useCallback, useLayoutEffect } from 'react';
import { createPortal } from 'react-dom';
import styles from './Tooltip.module.css';
export interface TooltipProps {
content: ReactNode;
placement?: 'top' | 'bottom' | 'left' | 'right';
children: ReactNode;
delay?: number;
/**
* Optional keyboard shortcut rendered as a row of keycaps under
* the main label. Accepts a lowercase `+`-joined combo string
* like `"ctrl+shift+m"` (the same format the KeybindStore uses).
* Empty / undefined hides the row entirely.
*/
shortcut?: string;
/**
* When true the tooltip stays open while the cursor is over the
* floating panel (not just the trigger), and clicks on the panel
* bubble through to `onContentClick`. Used for reaction chips
* where the tooltip doubles as a "click to view details" target.
*/
interactive?: boolean;
/**
* Fired when the user clicks the floating content. Only hooked
* up when `interactive` is true.
*/
onContentClick?: () => void;
}
/** Split a KeybindStore-format combo into display tokens and label
* each one so the Tooltip can render them as individual keycaps. */
function parseShortcut(combo: string): string[] {
if (!combo) return [];
return combo
.split('+')
.map((t) => t.trim())
.filter(Boolean)
.map(formatShortcutToken);
}
function formatShortcutToken(token: string): string {
// KeybindContext emits combos like `Ctrl+Shift+M` or `Ctrl+,` — so
// normalize the incoming token to lowercase for switch matching,
// but keep the original around for the default branch so
// punctuation and single letters round-trip as uppercase keycaps.
const lower = token.toLowerCase();
switch (lower) {
case 'ctrl':
case 'control':
return 'CTRL';
case 'shift':
return '⇧';
case 'alt':
case 'option':
return 'ALT';
case 'meta':
case 'cmd':
case 'command':
return '⌘';
case 'arrowup':
return '↑';
case 'arrowdown':
return '↓';
case 'arrowleft':
return '←';
case 'arrowright':
return '→';
case 'escape':
return 'ESC';
case 'enter':
return '↵';
case 'tab':
return 'TAB';
case ' ':
case 'space':
return 'SPACE';
default:
return token.length === 1 ? token.toUpperCase() : token.toUpperCase();
}
}
export function Tooltip({
content,
placement = 'top',
children,
delay = 300,
shortcut,
interactive = false,
onContentClick,
}: TooltipProps) {
const [isOpen, setIsOpen] = useState(false);
const referenceRef = useRef<HTMLElement | null>(null);
const floatingRef = useRef<HTMLDivElement | null>(null);
const openTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
// Close timer — separate from the open timer so we can cancel a
// pending close when the cursor enters the floating panel.
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const [pos, setPos] = useState<React.CSSProperties>({ position: 'fixed', visibility: 'hidden' });
/**
* Callback ref used when cloning the trigger. Stores the DOM node
* AND forwards it to whatever ref the caller already had on the
* child — without this forwarding the Tooltip would overwrite
* refs like `pinsButtonRef` and break any `getBoundingClientRect`
* logic on the consumer side.
*/
const makeSetRef = useCallback(
(childRef: any) => (node: HTMLElement | null) => {
referenceRef.current = node;
if (!childRef) return;
if (typeof childRef === 'function') {
childRef(node);
} else if (typeof childRef === 'object' && 'current' in childRef) {
(childRef as { current: HTMLElement | null }).current = node;
}
},
[],
);
// Compute position after the tooltip mounts and the reference is visible
useLayoutEffect(() => {
if (!isOpen || !referenceRef.current) return;
const computePos = () => {
const el = referenceRef.current;
const floating = floatingRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const floatingRect = floating?.getBoundingClientRect();
const fw = floatingRect?.width || 0;
const fh = floatingRect?.height || 0;
const style: React.CSSProperties = { position: 'fixed', zIndex: 20000 };
if (placement === 'top') {
style.left = rect.left + rect.width / 2 - fw / 2;
style.top = rect.top - fh - 8;
} else if (placement === 'bottom') {
style.left = rect.left + rect.width / 2 - fw / 2;
style.top = rect.bottom + 8;
} else if (placement === 'left') {
style.left = rect.left - fw - 8;
style.top = rect.top + rect.height / 2 - fh / 2;
} else {
style.left = rect.right + 8;
style.top = rect.top + rect.height / 2 - fh / 2;
}
setPos(style);
};
// First render: measure floating element then position
requestAnimationFrame(computePos);
}, [isOpen, placement]);
const handleMouseEnter = useCallback(() => {
if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current);
if (openTimeoutRef.current) clearTimeout(openTimeoutRef.current);
openTimeoutRef.current = setTimeout(() => setIsOpen(true), delay);
}, [delay]);
const handleMouseLeave = useCallback(() => {
if (openTimeoutRef.current) clearTimeout(openTimeoutRef.current);
// Interactive mode: give the user a short grace window to
// move from the trigger onto the floating panel. Non-
// interactive tooltips still close instantly to match the
// classic label behaviour.
if (interactive) {
if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = setTimeout(() => setIsOpen(false), 150);
} else {
setIsOpen(false);
}
}, [interactive]);
const handleFloatingEnter = useCallback(() => {
if (!interactive) return;
if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current);
}, [interactive]);
const handleFloatingLeave = useCallback(() => {
if (!interactive) return;
if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = setTimeout(() => setIsOpen(false), 150);
}, [interactive]);
return (
<>
{isValidElement(children) &&
cloneElement(children as React.ReactElement<any>, {
ref: makeSetRef((children as any).ref),
onMouseEnter: handleMouseEnter,
onMouseLeave: handleMouseLeave,
onFocus: handleMouseEnter,
onBlur: handleMouseLeave,
})}
{createPortal(
<AnimatePresence>
{isOpen && (
<motion.div
ref={floatingRef}
style={{
...pos,
// Interactive tooltips must accept pointer events so
// the user can hover onto them and click them. The
// default tooltip is non-interactive and leaves the
// CSS default alone.
pointerEvents: interactive ? 'auto' : undefined,
cursor: interactive && onContentClick ? 'pointer' : undefined,
// Interactive tooltips often carry richer content
// (emoji glyph, multi-line body) — give them a wider
// clamp than the default 220px label limit.
maxWidth: interactive ? 280 : undefined,
}}
className={styles.tooltip}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.1 }}
role={interactive && onContentClick ? 'button' : 'tooltip'}
onMouseEnter={handleFloatingEnter}
onMouseLeave={handleFloatingLeave}
onClick={
interactive && onContentClick
? () => {
if (closeTimeoutRef.current)
clearTimeout(closeTimeoutRef.current);
setIsOpen(false);
onContentClick();
}
: undefined
}
>
<div className={styles.content}>{content}</div>
{shortcut && (
<div className={styles.shortcutRow}>
{parseShortcut(shortcut).map((token, i) => (
<span key={`${i}-${token}`} className={styles.keycap}>
{token}
</span>
))}
</div>
)}
</motion.div>
)}
</AnimatePresence>,
document.body,
)}
</>
);
}

4
packages/ui/src/css-modules.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare module '*.module.css' {
const classes: { readonly [key: string]: string };
export default classes;
}

8
packages/ui/src/index.ts Normal file
View File

@@ -0,0 +1,8 @@
export { Avatar } from './Avatar';
export { Button } from './Button';
export { Tooltip } from './Tooltip';
export { Modal } from './Modal';
export { BottomSheet } from './BottomSheet';
export type { BottomSheetProps } from './BottomSheet';
export { Spinner } from './Spinner';
export { Toast } from './Toast';

View File

@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src"]
}