270 lines
9.9 KiB
JavaScript
270 lines
9.9 KiB
JavaScript
// MOMO Pro - 共用 UI 元件
|
||
|
||
// ===== Badge =====
|
||
const Badge = ({ tone = 'secondary', children, dot = false, style }) => {
|
||
const tones = {
|
||
primary: { bg: 'var(--momo-primary-100)', text: 'var(--momo-primary-700)', dot: 'var(--momo-primary)' },
|
||
success: { bg: 'var(--momo-success-bg)', text: 'var(--momo-success-text)', dot: 'var(--momo-success)' },
|
||
danger: { bg: 'var(--momo-danger-bg)', text: 'var(--momo-danger-text)', dot: 'var(--momo-danger)' },
|
||
warning: { bg: 'var(--momo-warning-bg)', text: 'var(--momo-warning-text)', dot: 'var(--momo-warning)' },
|
||
info: { bg: 'var(--momo-info-bg)', text: 'var(--momo-info-text)', dot: 'var(--momo-info)' },
|
||
secondary: { bg: 'var(--momo-bg-muted)', text: 'var(--momo-text-secondary)', dot: 'var(--momo-text-tertiary)' },
|
||
};
|
||
const t = tones[tone] || tones.secondary;
|
||
return (
|
||
<span style={{
|
||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||
padding: '3px 10px',
|
||
background: t.bg, color: t.text,
|
||
fontSize: 'var(--momo-font-size-xs)',
|
||
fontWeight: 'var(--momo-font-weight-medium)',
|
||
borderRadius: 'var(--momo-radius-pill)',
|
||
lineHeight: 1.5,
|
||
whiteSpace: 'nowrap',
|
||
...style,
|
||
}}>
|
||
{dot && <span style={{ width: 6, height: 6, borderRadius: '50%', background: t.dot }} />}
|
||
{children}
|
||
</span>
|
||
);
|
||
};
|
||
|
||
// ===== Button =====
|
||
const Button = ({ variant = 'gradient', size = 'md', icon, iconRight, children, onClick, style, disabled, type = 'button' }) => {
|
||
const sizes = {
|
||
sm: { padding: '6px 12px', fontSize: 'var(--momo-font-size-xs)', height: 30, gap: 6 },
|
||
md: { padding: '8px 16px', fontSize: 'var(--momo-font-size-sm)', height: 38, gap: 8 },
|
||
lg: { padding: '10px 20px', fontSize: 'var(--momo-font-size-base)', height: 44, gap: 10 },
|
||
};
|
||
const variants = {
|
||
gradient: {
|
||
background: 'var(--momo-gradient-primary)',
|
||
color: 'var(--momo-text-inverse)',
|
||
boxShadow: 'var(--momo-shadow-sm)',
|
||
},
|
||
solid: {
|
||
background: 'var(--momo-primary)',
|
||
color: 'var(--momo-text-inverse)',
|
||
},
|
||
outline: {
|
||
background: 'var(--momo-bg-surface)',
|
||
color: 'var(--momo-primary)',
|
||
border: '1.5px solid var(--momo-primary)',
|
||
},
|
||
ghost: {
|
||
background: 'transparent',
|
||
color: 'var(--momo-text-secondary)',
|
||
},
|
||
'ghost-hover': {
|
||
background: 'var(--momo-bg-subtle)',
|
||
color: 'var(--momo-text-primary)',
|
||
},
|
||
secondary: {
|
||
background: 'var(--momo-bg-surface)',
|
||
color: 'var(--momo-text-primary)',
|
||
border: '1px solid var(--momo-border)',
|
||
},
|
||
danger: {
|
||
background: 'var(--momo-danger)',
|
||
color: 'var(--momo-text-inverse)',
|
||
},
|
||
};
|
||
const s = sizes[size];
|
||
const v = variants[variant] || variants.gradient;
|
||
return (
|
||
<button type={type} onClick={onClick} disabled={disabled}
|
||
className="momo-btn"
|
||
style={{
|
||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||
gap: s.gap, padding: s.padding, fontSize: s.fontSize, height: s.height,
|
||
fontWeight: 'var(--momo-font-weight-medium)',
|
||
borderRadius: 'var(--momo-radius-md)',
|
||
border: 'none',
|
||
transition: 'var(--momo-transition-base), transform var(--momo-duration-fast)',
|
||
opacity: disabled ? 0.5 : 1,
|
||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||
...v,
|
||
...style,
|
||
}}>
|
||
{icon && <Icon name={icon} size={size === 'sm' ? 14 : 16} />}
|
||
{children}
|
||
{iconRight && <Icon name={iconRight} size={size === 'sm' ? 14 : 16} />}
|
||
</button>
|
||
);
|
||
};
|
||
|
||
// ===== Avatar =====
|
||
const Avatar = ({ name = '?', size = 32, gradient = false, style }) => {
|
||
const initial = (name || '?').trim().charAt(0).toUpperCase();
|
||
// 從名字 hash 出穩定色相
|
||
let hash = 0;
|
||
for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||
const hue = Math.abs(hash) % 360;
|
||
return (
|
||
<div style={{
|
||
width: size, height: size,
|
||
borderRadius: '50%',
|
||
background: gradient ? 'var(--momo-gradient-primary)' : `hsl(${hue}, 65%, 88%)`,
|
||
color: gradient ? '#fff' : `hsl(${hue}, 70%, 30%)`,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
fontSize: size * 0.42,
|
||
fontWeight: 600,
|
||
flexShrink: 0,
|
||
...style,
|
||
}}>{initial}</div>
|
||
);
|
||
};
|
||
|
||
// ===== Card =====
|
||
const Card = ({ children, style, cardStyle = 'shadow', padding = true, hoverable = false }) => {
|
||
const styles = {
|
||
shadow: { boxShadow: 'var(--momo-shadow-md)', border: '1px solid transparent' },
|
||
flat: { boxShadow: 'none', border: '1px solid transparent', background: 'var(--momo-bg-subtle)' },
|
||
bordered:{ boxShadow: 'none', border: '1px solid var(--momo-border)' },
|
||
};
|
||
return (
|
||
<div style={{
|
||
background: 'var(--momo-bg-surface)',
|
||
borderRadius: 'var(--momo-radius-lg)',
|
||
padding: padding ? 'var(--momo-space-5)' : 0,
|
||
transition: 'var(--momo-transition-base), transform var(--momo-duration-fast)',
|
||
...styles[cardStyle],
|
||
...(hoverable ? { cursor: 'pointer' } : {}),
|
||
...style,
|
||
}}>{children}</div>
|
||
);
|
||
};
|
||
|
||
// ===== Input =====
|
||
const Input = ({ icon, value, onChange, placeholder, style, type = 'text', size = 'md' }) => {
|
||
const heights = { sm: 32, md: 38, lg: 44 };
|
||
return (
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center',
|
||
background: 'var(--momo-bg-surface)',
|
||
border: '1px solid var(--momo-border)',
|
||
borderRadius: 'var(--momo-radius-md)',
|
||
padding: '0 12px',
|
||
height: heights[size],
|
||
transition: 'var(--momo-transition-base)',
|
||
...style,
|
||
}}>
|
||
{icon && <Icon name={icon} size={16} color="var(--momo-text-tertiary)" style={{ marginRight: 8 }} />}
|
||
<input type={type} value={value} onChange={onChange} placeholder={placeholder}
|
||
style={{
|
||
flex: 1, border: 'none', outline: 'none', background: 'transparent',
|
||
fontSize: 'var(--momo-font-size-sm)',
|
||
color: 'var(--momo-text-primary)',
|
||
}} />
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ===== Checkbox =====
|
||
const Checkbox = ({ checked, onChange, indeterminate = false, style }) => {
|
||
const ref = React.useRef(null);
|
||
React.useEffect(() => {
|
||
if (ref.current) ref.current.indeterminate = indeterminate;
|
||
}, [indeterminate]);
|
||
return (
|
||
<label style={{
|
||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||
cursor: 'pointer', position: 'relative',
|
||
width: 18, height: 18,
|
||
...style,
|
||
}}>
|
||
<input ref={ref} type="checkbox" checked={!!checked} onChange={onChange}
|
||
style={{ position: 'absolute', opacity: 0, width: 0, height: 0 }} />
|
||
<span style={{
|
||
width: 18, height: 18,
|
||
borderRadius: 4,
|
||
border: `1.5px solid ${checked || indeterminate ? 'var(--momo-primary)' : 'var(--momo-border-dark)'}`,
|
||
background: checked || indeterminate ? 'var(--momo-gradient-primary)' : 'transparent',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
transition: 'var(--momo-transition-base)',
|
||
}}>
|
||
{checked && <Icon name="check" size={12} color="#fff" strokeWidth={3} />}
|
||
{indeterminate && !checked && <span style={{ width: 8, height: 2, background: '#fff', borderRadius: 1 }} />}
|
||
</span>
|
||
</label>
|
||
);
|
||
};
|
||
|
||
// ===== Page Header =====
|
||
const PageHeader = ({ title, subtitle, actions, breadcrumbs }) => (
|
||
<div style={{
|
||
display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between',
|
||
gap: 'var(--momo-space-4)',
|
||
marginBottom: 'var(--momo-space-5)',
|
||
}}>
|
||
<div>
|
||
{breadcrumbs && (
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', gap: 6,
|
||
fontSize: 'var(--momo-font-size-xs)',
|
||
color: 'var(--momo-text-tertiary)',
|
||
marginBottom: 6,
|
||
}}>
|
||
{breadcrumbs.map((b, i) => (
|
||
<React.Fragment key={i}>
|
||
{i > 0 && <Icon name="chevronRight" size={12} />}
|
||
<span style={{ color: i === breadcrumbs.length - 1 ? 'var(--momo-text-secondary)' : 'inherit' }}>{b}</span>
|
||
</React.Fragment>
|
||
))}
|
||
</div>
|
||
)}
|
||
<h1 style={{
|
||
margin: 0,
|
||
fontSize: 'var(--momo-font-size-xl)',
|
||
fontWeight: 'var(--momo-font-weight-bold)',
|
||
color: 'var(--momo-text-primary)',
|
||
letterSpacing: '-0.01em',
|
||
}}>{title}</h1>
|
||
{subtitle && <div style={{
|
||
marginTop: 4,
|
||
fontSize: 'var(--momo-font-size-sm)',
|
||
color: 'var(--momo-text-secondary)',
|
||
}}>{subtitle}</div>}
|
||
</div>
|
||
{actions && <div style={{ display: 'flex', gap: 8 }}>{actions}</div>}
|
||
</div>
|
||
);
|
||
|
||
// ===== Tag(標籤統一元件 — 全站唯一入口) =====
|
||
// 用法:<Tag tone="caramel">推薦</Tag> <Tag tone="ink" mono>MOMO 領先</Tag>
|
||
// tone: caramel | honey | rust | mahogany | earth | ink | muted | success
|
||
// 不要再用任何寫死色碼做標籤!
|
||
const Tag = ({ tone = 'earth', mono = false, dot = false, size = 'sm', children, style }) => {
|
||
const sizes = {
|
||
xs: { pad: '1px 6px', fs: 10, gap: 4 },
|
||
sm: { pad: '2px 8px', fs: 11, gap: 5 },
|
||
md: { pad: '3px 10px', fs: 12, gap: 6 },
|
||
};
|
||
const s = sizes[size] || sizes.sm;
|
||
return (
|
||
<span style={{
|
||
display: 'inline-flex', alignItems: 'center', gap: s.gap,
|
||
padding: s.pad,
|
||
fontSize: s.fs,
|
||
fontWeight: 600,
|
||
lineHeight: 1.5,
|
||
whiteSpace: 'nowrap',
|
||
borderRadius: 3,
|
||
background: `var(--momo-tag-${tone}-bg)`,
|
||
color: `var(--momo-tag-${tone}-text)`,
|
||
border: `1px solid var(--momo-tag-${tone}-border)`,
|
||
fontFamily: mono ? 'var(--momo-font-family-mono)' : 'var(--momo-font-family-base)',
|
||
letterSpacing: mono ? '0.02em' : 0,
|
||
...style,
|
||
}}>
|
||
{dot && <span style={{
|
||
width: 5, height: 5, borderRadius: '50%',
|
||
background: `var(--momo-tag-${tone}-text)`, opacity: 0.7,
|
||
}} />}
|
||
{children}
|
||
</span>
|
||
);
|
||
};
|
||
|
||
Object.assign(window, { Badge, Button, Avatar, Card, Input, Checkbox, PageHeader, Tag });
|