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:
18
packages/ui/package.json
Normal file
18
packages/ui/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
51
packages/ui/src/Avatar.module.css
Normal file
51
packages/ui/src/Avatar.module.css
Normal 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);
|
||||
}
|
||||
93
packages/ui/src/Avatar.tsx
Normal file
93
packages/ui/src/Avatar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
packages/ui/src/BottomSheet.module.css
Normal file
109
packages/ui/src/BottomSheet.module.css
Normal 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;
|
||||
}
|
||||
258
packages/ui/src/BottomSheet.tsx
Normal file
258
packages/ui/src/BottomSheet.tsx
Normal 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 (0–95).
|
||||
* 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,
|
||||
);
|
||||
}
|
||||
270
packages/ui/src/Button.module.css
Normal file
270
packages/ui/src/Button.module.css
Normal 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
108
packages/ui/src/Button.tsx
Normal 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';
|
||||
119
packages/ui/src/Modal.module.css
Normal file
119
packages/ui/src/Modal.module.css
Normal 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
190
packages/ui/src/Modal.tsx
Normal 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,
|
||||
});
|
||||
26
packages/ui/src/Spinner.tsx
Normal file
26
packages/ui/src/Spinner.tsx
Normal 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
71
packages/ui/src/Toast.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
packages/ui/src/Tooltip.module.css
Normal file
54
packages/ui/src/Tooltip.module.css
Normal 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
254
packages/ui/src/Tooltip.tsx
Normal 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
4
packages/ui/src/css-modules.d.ts
vendored
Normal 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
8
packages/ui/src/index.ts
Normal 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';
|
||||
4
packages/ui/tsconfig.json
Normal file
4
packages/ui/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user