@1889ca/ui 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/ContextMenu.css +5 -3
- package/src/components/ContextMenu.jsx +2 -14
- package/src/components/DraggablePanel.css +2 -1
- package/src/components/DraggablePanel.jsx +4 -17
- package/src/components/EmptyState.css +36 -0
- package/src/components/EmptyState.jsx +27 -0
- package/src/components/ItemCard.css +12 -1
- package/src/components/ItemCard.jsx +8 -3
- package/src/components/ItemGrid.css +22 -0
- package/src/components/Lightbox.css +12 -2
- package/src/components/Lightbox.jsx +2 -2
- package/src/components/LoginCard.css +1 -1
- package/src/components/Modal.css +5 -4
- package/src/components/PageHeader.css +15 -1
- package/src/components/Panel.css +1 -2
- package/src/components/Panel.jsx +2 -2
- package/src/components/Skeleton.css +79 -0
- package/src/components/Skeleton.jsx +47 -0
- package/src/components/SlidePanel.css +7 -1
- package/src/components/StatusBar.css +38 -1
- package/src/components/TabBar.css +53 -0
- package/src/components/TabBar.jsx +28 -0
- package/src/components/ThemeToggle.css +34 -0
- package/src/components/ThemeToggle.jsx +44 -0
- package/src/components/Toast.css +102 -0
- package/src/components/Toast.jsx +67 -0
- package/src/components/Toggle.css +53 -0
- package/src/components/Toggle.jsx +28 -0
- package/src/components/Tooltip.css +36 -0
- package/src/components/Tooltip.jsx +55 -0
- package/src/components/UserMenu.css +18 -1
- package/src/components/UserMenu.jsx +28 -18
- package/src/effects/Particles.jsx +117 -0
- package/src/effects/ambient-glow.css +67 -0
- package/src/effects/edge-light.css +69 -0
- package/src/effects/glass-tint.css +47 -0
- package/src/effects/glow-focus.css +64 -0
- package/src/effects/haptic.css +52 -0
- package/src/effects/particles.css +10 -0
- package/src/effects/scroll-parallax.css +46 -0
- package/src/hooks/useClickOutside.js +26 -0
- package/src/hooks/useLocalStorage.js +25 -0
- package/src/hooks/useMagnet.js +60 -0
- package/src/hooks/useMediaQuery.js +24 -0
- package/src/hooks/useScrollParallax.js +39 -0
- package/src/hooks/useSound.js +106 -0
- package/src/hooks/useTheme.js +90 -0
- package/src/index.js +36 -0
- package/src/styles/primitives.css +48 -1
- package/src/styles/tokens.css +89 -0
- package/src/utils/accentColors.js +85 -0
- package/src/utils/viewTransition.js +16 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** Contract: contracts/packages-ui-components/rules.md */
|
|
2
|
+
|
|
3
|
+
.ui-tabbar {
|
|
4
|
+
display: flex;
|
|
5
|
+
gap: 2px;
|
|
6
|
+
padding: 0 0.35rem;
|
|
7
|
+
border-bottom: 1px solid var(--ui-border);
|
|
8
|
+
flex-shrink: 0;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.ui-tabbar--full .ui-tabbar-tab {
|
|
12
|
+
flex: 1;
|
|
13
|
+
text-align: center;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.ui-tabbar-tab {
|
|
17
|
+
position: relative;
|
|
18
|
+
padding: 0.5rem 0.75rem;
|
|
19
|
+
background: none;
|
|
20
|
+
border: none;
|
|
21
|
+
color: var(--ui-text-subtle);
|
|
22
|
+
font-size: 0.72rem;
|
|
23
|
+
font-weight: 500;
|
|
24
|
+
text-transform: uppercase;
|
|
25
|
+
letter-spacing: 0.05em;
|
|
26
|
+
cursor: pointer;
|
|
27
|
+
transition: color 0.15s var(--ui-ease);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.ui-tabbar-tab::after {
|
|
31
|
+
content: '';
|
|
32
|
+
position: absolute;
|
|
33
|
+
left: 0.35rem;
|
|
34
|
+
right: 0.35rem;
|
|
35
|
+
bottom: -1px;
|
|
36
|
+
height: 2px;
|
|
37
|
+
border-radius: 1px;
|
|
38
|
+
background: transparent;
|
|
39
|
+
transition: background 0.2s var(--ui-ease), box-shadow 0.2s var(--ui-ease);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.ui-tabbar-tab:hover {
|
|
43
|
+
color: var(--ui-text-muted);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.ui-tabbar-tab--active {
|
|
47
|
+
color: var(--ui-accent);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.ui-tabbar-tab--active::after {
|
|
51
|
+
background: var(--ui-accent);
|
|
52
|
+
box-shadow: 0 0 8px var(--ui-accent-glow);
|
|
53
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Contract: contracts/packages-ui-components/rules.md */
|
|
2
|
+
import './TabBar.css';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Standalone tab bar with animated underline indicator.
|
|
6
|
+
* @param {Array} tabs - [{ id, label }]
|
|
7
|
+
* @param {string} activeTab - Currently active tab id
|
|
8
|
+
* @param {function} onChange - Called with tab id
|
|
9
|
+
* @param {string} variant - 'default' | 'full-width' (default: 'default')
|
|
10
|
+
*/
|
|
11
|
+
export default function TabBar({ tabs, activeTab, onChange, variant = 'default' }) {
|
|
12
|
+
return (
|
|
13
|
+
<nav className={`ui-tabbar${variant === 'full-width' ? ' ui-tabbar--full' : ''}`} role="tablist">
|
|
14
|
+
{tabs.map((tab) => (
|
|
15
|
+
<button
|
|
16
|
+
key={tab.id}
|
|
17
|
+
type="button"
|
|
18
|
+
role="tab"
|
|
19
|
+
aria-selected={activeTab === tab.id}
|
|
20
|
+
className={`ui-tabbar-tab${activeTab === tab.id ? ' ui-tabbar-tab--active' : ''}`}
|
|
21
|
+
onClick={() => onChange?.(tab.id)}
|
|
22
|
+
>
|
|
23
|
+
{tab.label}
|
|
24
|
+
</button>
|
|
25
|
+
))}
|
|
26
|
+
</nav>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Contract: contracts/packages-ui-components/rules.md */
|
|
2
|
+
|
|
3
|
+
.ui-theme-toggle {
|
|
4
|
+
display: inline-flex;
|
|
5
|
+
align-items: center;
|
|
6
|
+
justify-content: center;
|
|
7
|
+
width: 32px;
|
|
8
|
+
height: 32px;
|
|
9
|
+
border-radius: var(--ui-radius-sm);
|
|
10
|
+
border: 1px solid var(--ui-border);
|
|
11
|
+
background: var(--ui-surface);
|
|
12
|
+
color: var(--ui-text-muted);
|
|
13
|
+
cursor: pointer;
|
|
14
|
+
padding: 0;
|
|
15
|
+
transition: color 0.2s var(--ui-ease), border-color 0.2s var(--ui-ease), background 0.2s var(--ui-ease), transform 0.2s var(--ui-ease-spring);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.ui-theme-toggle:hover {
|
|
19
|
+
color: var(--ui-text);
|
|
20
|
+
border-color: var(--ui-border-strong);
|
|
21
|
+
background: var(--ui-surface-raised);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.ui-theme-toggle:active {
|
|
25
|
+
transform: scale(0.92);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.ui-theme-toggle svg {
|
|
29
|
+
transition: transform 0.3s var(--ui-ease-spring);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.ui-theme-toggle:hover svg {
|
|
33
|
+
transform: rotate(15deg);
|
|
34
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Contract: contracts/packages-ui-components/rules.md */
|
|
2
|
+
import useTheme from '../hooks/useTheme.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Theme toggle button — cycles between dark/light.
|
|
6
|
+
* Uses useTheme internally; can also accept external theme state.
|
|
7
|
+
*
|
|
8
|
+
* @param {string} [size='20'] - Icon size
|
|
9
|
+
* @param {string} [className] - Additional class names
|
|
10
|
+
* @param {object} [themeState] - External { resolvedTheme, toggle } from useTheme (optional)
|
|
11
|
+
*/
|
|
12
|
+
export default function ThemeToggle({ size = 20, className, themeState }) {
|
|
13
|
+
const internal = useTheme();
|
|
14
|
+
const { resolvedTheme, toggle } = themeState || internal;
|
|
15
|
+
const isDark = resolvedTheme === 'dark';
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<button
|
|
19
|
+
type="button"
|
|
20
|
+
className={`ui-theme-toggle${className ? ` ${className}` : ''}`}
|
|
21
|
+
onClick={toggle}
|
|
22
|
+
aria-label={`Switch to ${isDark ? 'light' : 'dark'} mode`}
|
|
23
|
+
title={`Switch to ${isDark ? 'light' : 'dark'} mode`}
|
|
24
|
+
>
|
|
25
|
+
{isDark ? (
|
|
26
|
+
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
27
|
+
<circle cx="12" cy="12" r="5" />
|
|
28
|
+
<line x1="12" y1="1" x2="12" y2="3" />
|
|
29
|
+
<line x1="12" y1="21" x2="12" y2="23" />
|
|
30
|
+
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
|
|
31
|
+
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
|
|
32
|
+
<line x1="1" y1="12" x2="3" y2="12" />
|
|
33
|
+
<line x1="21" y1="12" x2="23" y2="12" />
|
|
34
|
+
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
|
|
35
|
+
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
|
|
36
|
+
</svg>
|
|
37
|
+
) : (
|
|
38
|
+
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
39
|
+
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
|
40
|
+
</svg>
|
|
41
|
+
)}
|
|
42
|
+
</button>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/** Contract: contracts/packages-ui-components/rules.md */
|
|
2
|
+
|
|
3
|
+
.ui-toast-container {
|
|
4
|
+
position: fixed;
|
|
5
|
+
bottom: 40px;
|
|
6
|
+
right: 1rem;
|
|
7
|
+
z-index: 1100;
|
|
8
|
+
display: flex;
|
|
9
|
+
flex-direction: column;
|
|
10
|
+
gap: 0.5rem;
|
|
11
|
+
pointer-events: none;
|
|
12
|
+
max-width: calc(100vw - 2rem);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.ui-toast {
|
|
16
|
+
display: flex;
|
|
17
|
+
align-items: center;
|
|
18
|
+
gap: 0.6rem;
|
|
19
|
+
padding: 0.6rem 1rem;
|
|
20
|
+
min-width: 220px;
|
|
21
|
+
max-width: 400px;
|
|
22
|
+
border-radius: var(--ui-radius);
|
|
23
|
+
border: 1px solid var(--ui-border);
|
|
24
|
+
background: var(--ui-glass-heavy);
|
|
25
|
+
backdrop-filter: blur(var(--ui-glass-blur-heavy));
|
|
26
|
+
box-shadow: var(--ui-shadow-float);
|
|
27
|
+
font-size: 0.82rem;
|
|
28
|
+
color: var(--ui-text);
|
|
29
|
+
cursor: pointer;
|
|
30
|
+
pointer-events: auto;
|
|
31
|
+
animation: ui-toast-in 0.35s var(--ui-ease-spring);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.ui-toast--leaving {
|
|
35
|
+
animation: ui-toast-out 0.3s var(--ui-ease) forwards;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@keyframes ui-toast-in {
|
|
39
|
+
from {
|
|
40
|
+
opacity: 0;
|
|
41
|
+
transform: translateX(100%) scale(0.95);
|
|
42
|
+
}
|
|
43
|
+
to {
|
|
44
|
+
opacity: 1;
|
|
45
|
+
transform: translateX(0) scale(1);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
@keyframes ui-toast-out {
|
|
50
|
+
to {
|
|
51
|
+
opacity: 0;
|
|
52
|
+
transform: translateX(30%) scale(0.95);
|
|
53
|
+
max-height: 0;
|
|
54
|
+
padding-top: 0;
|
|
55
|
+
padding-bottom: 0;
|
|
56
|
+
margin-bottom: -0.5rem;
|
|
57
|
+
overflow: hidden;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.ui-toast-icon {
|
|
62
|
+
flex-shrink: 0;
|
|
63
|
+
width: 18px;
|
|
64
|
+
height: 18px;
|
|
65
|
+
border-radius: 50%;
|
|
66
|
+
display: flex;
|
|
67
|
+
align-items: center;
|
|
68
|
+
justify-content: center;
|
|
69
|
+
font-size: 0.7rem;
|
|
70
|
+
font-weight: 700;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.ui-toast--info {
|
|
74
|
+
border-left: 3px solid var(--ui-accent);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.ui-toast--info .ui-toast-icon {
|
|
78
|
+
background: var(--ui-accent-muted);
|
|
79
|
+
color: var(--ui-accent);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.ui-toast--success {
|
|
83
|
+
border-left: 3px solid var(--ui-success);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.ui-toast--success .ui-toast-icon {
|
|
87
|
+
background: rgba(52, 211, 153, 0.15);
|
|
88
|
+
color: var(--ui-success);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.ui-toast--error {
|
|
92
|
+
border-left: 3px solid var(--ui-danger);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.ui-toast--error .ui-toast-icon {
|
|
96
|
+
background: var(--ui-danger-glow);
|
|
97
|
+
color: var(--ui-danger);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.ui-toast-message {
|
|
101
|
+
line-height: 1.4;
|
|
102
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** Contract: contracts/packages-ui-components/rules.md */
|
|
2
|
+
import { createContext, useContext, useState, useCallback, useRef } from 'react';
|
|
3
|
+
import './Toast.css';
|
|
4
|
+
|
|
5
|
+
const ToastContext = createContext(null);
|
|
6
|
+
|
|
7
|
+
let toastId = 0;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Wrap your app in ToastProvider to enable toasts.
|
|
11
|
+
* Use the useToast() hook to show toasts imperatively.
|
|
12
|
+
*/
|
|
13
|
+
export function ToastProvider({ children }) {
|
|
14
|
+
const [toasts, setToasts] = useState([]);
|
|
15
|
+
const timers = useRef({});
|
|
16
|
+
|
|
17
|
+
const dismiss = useCallback((id) => {
|
|
18
|
+
setToasts(prev => prev.map(t => t.id === id ? { ...t, leaving: true } : t));
|
|
19
|
+
setTimeout(() => setToasts(prev => prev.filter(t => t.id !== id)), 300);
|
|
20
|
+
}, []);
|
|
21
|
+
|
|
22
|
+
const toast = useCallback(({ message, type = 'info', duration = 4000 }) => {
|
|
23
|
+
const id = ++toastId;
|
|
24
|
+
setToasts(prev => [...prev, { id, message, type, leaving: false }]);
|
|
25
|
+
if (duration > 0) {
|
|
26
|
+
timers.current[id] = setTimeout(() => dismiss(id), duration);
|
|
27
|
+
}
|
|
28
|
+
return id;
|
|
29
|
+
}, [dismiss]);
|
|
30
|
+
|
|
31
|
+
const api = useCallback((message, type) => {
|
|
32
|
+
if (typeof message === 'object') return toast(message);
|
|
33
|
+
return toast({ message, type });
|
|
34
|
+
}, [toast]);
|
|
35
|
+
|
|
36
|
+
api.success = (message, duration) => toast({ message, type: 'success', duration });
|
|
37
|
+
api.error = (message, duration) => toast({ message, type: 'error', duration: duration ?? 6000 });
|
|
38
|
+
api.info = (message, duration) => toast({ message, type: 'info', duration });
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<ToastContext.Provider value={api}>
|
|
42
|
+
{children}
|
|
43
|
+
<div className="ui-toast-container" aria-live="polite">
|
|
44
|
+
{toasts.map((t) => (
|
|
45
|
+
<div
|
|
46
|
+
key={t.id}
|
|
47
|
+
className={`ui-toast ui-toast--${t.type}${t.leaving ? ' ui-toast--leaving' : ''}`}
|
|
48
|
+
onClick={() => { clearTimeout(timers.current[t.id]); dismiss(t.id); }}
|
|
49
|
+
>
|
|
50
|
+
<span className="ui-toast-icon">{t.type === 'success' ? '✓' : t.type === 'error' ? '✕' : 'ℹ'}</span>
|
|
51
|
+
<span className="ui-toast-message">{t.message}</span>
|
|
52
|
+
</div>
|
|
53
|
+
))}
|
|
54
|
+
</div>
|
|
55
|
+
</ToastContext.Provider>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Returns the toast function.
|
|
61
|
+
* toast('message') or toast.success('done') / toast.error('failed') / toast.info('note')
|
|
62
|
+
*/
|
|
63
|
+
export function useToast() {
|
|
64
|
+
const ctx = useContext(ToastContext);
|
|
65
|
+
if (!ctx) throw new Error('useToast requires <ToastProvider>');
|
|
66
|
+
return ctx;
|
|
67
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** Contract: contracts/packages-ui-components/rules.md */
|
|
2
|
+
|
|
3
|
+
.ui-toggle {
|
|
4
|
+
display: inline-flex;
|
|
5
|
+
align-items: center;
|
|
6
|
+
gap: 0.5rem;
|
|
7
|
+
cursor: pointer;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.ui-toggle--disabled {
|
|
11
|
+
opacity: 0.4;
|
|
12
|
+
cursor: not-allowed;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.ui-toggle-track {
|
|
16
|
+
position: relative;
|
|
17
|
+
width: 36px;
|
|
18
|
+
height: 20px;
|
|
19
|
+
border-radius: 10px;
|
|
20
|
+
border: 1px solid var(--ui-border-strong);
|
|
21
|
+
background: var(--ui-surface);
|
|
22
|
+
cursor: inherit;
|
|
23
|
+
padding: 0;
|
|
24
|
+
transition: background 0.2s var(--ui-ease), border-color 0.2s var(--ui-ease), box-shadow 0.2s var(--ui-ease);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.ui-toggle-track--on {
|
|
28
|
+
background: var(--ui-accent);
|
|
29
|
+
border-color: var(--ui-accent);
|
|
30
|
+
box-shadow: 0 0 12px var(--ui-accent-glow);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.ui-toggle-knob {
|
|
34
|
+
position: absolute;
|
|
35
|
+
top: 2px;
|
|
36
|
+
left: 2px;
|
|
37
|
+
width: 14px;
|
|
38
|
+
height: 14px;
|
|
39
|
+
border-radius: 50%;
|
|
40
|
+
background: var(--ui-text-muted);
|
|
41
|
+
transition: transform 0.2s var(--ui-ease-spring), background 0.2s var(--ui-ease);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.ui-toggle-track--on .ui-toggle-knob {
|
|
45
|
+
transform: translateX(16px);
|
|
46
|
+
background: #fff;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.ui-toggle-label {
|
|
50
|
+
font-size: 0.82rem;
|
|
51
|
+
color: var(--ui-text-muted);
|
|
52
|
+
user-select: none;
|
|
53
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Contract: contracts/packages-ui-components/rules.md */
|
|
2
|
+
import './Toggle.css';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Toggle switch with animated knob.
|
|
6
|
+
* @param {boolean} checked
|
|
7
|
+
* @param {function} onChange - called with new boolean value
|
|
8
|
+
* @param {string} label - Optional accessible label
|
|
9
|
+
* @param {boolean} disabled
|
|
10
|
+
*/
|
|
11
|
+
export default function Toggle({ checked, onChange, label, disabled }) {
|
|
12
|
+
return (
|
|
13
|
+
<label className={`ui-toggle${disabled ? ' ui-toggle--disabled' : ''}`}>
|
|
14
|
+
<button
|
|
15
|
+
type="button"
|
|
16
|
+
role="switch"
|
|
17
|
+
aria-checked={checked}
|
|
18
|
+
aria-label={label}
|
|
19
|
+
className={`ui-toggle-track${checked ? ' ui-toggle-track--on' : ''}`}
|
|
20
|
+
onClick={() => !disabled && onChange?.(!checked)}
|
|
21
|
+
disabled={disabled}
|
|
22
|
+
>
|
|
23
|
+
<span className="ui-toggle-knob" />
|
|
24
|
+
</button>
|
|
25
|
+
{label && <span className="ui-toggle-label">{label}</span>}
|
|
26
|
+
</label>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Contract: contracts/packages-ui-components/rules.md */
|
|
2
|
+
|
|
3
|
+
.ui-tooltip-trigger {
|
|
4
|
+
display: inline-flex;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.ui-tooltip {
|
|
8
|
+
position: fixed;
|
|
9
|
+
z-index: 1200;
|
|
10
|
+
padding: 0.3rem 0.6rem;
|
|
11
|
+
font-size: 0.72rem;
|
|
12
|
+
font-weight: 500;
|
|
13
|
+
color: var(--ui-text);
|
|
14
|
+
background: var(--ui-glass-heavy);
|
|
15
|
+
backdrop-filter: blur(var(--ui-glass-blur));
|
|
16
|
+
border: 1px solid var(--ui-border-strong);
|
|
17
|
+
border-radius: var(--ui-radius-sm);
|
|
18
|
+
box-shadow: var(--ui-shadow-lg);
|
|
19
|
+
white-space: nowrap;
|
|
20
|
+
pointer-events: none;
|
|
21
|
+
animation: ui-tooltip-in 0.15s var(--ui-ease);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@keyframes ui-tooltip-in {
|
|
25
|
+
from { opacity: 0; transform: translate(-50%, -100%) scale(0.95); }
|
|
26
|
+
to { opacity: 1; transform: translate(-50%, -100%) scale(1); }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.ui-tooltip--bottom {
|
|
30
|
+
animation-name: ui-tooltip-in-bottom;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@keyframes ui-tooltip-in-bottom {
|
|
34
|
+
from { opacity: 0; transform: translate(-50%, 0) scale(0.95); }
|
|
35
|
+
to { opacity: 1; transform: translate(-50%, 0) scale(1); }
|
|
36
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** Contract: contracts/packages-ui-components/rules.md */
|
|
2
|
+
import { useState, useRef, useEffect } from 'react';
|
|
3
|
+
import './Tooltip.css';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Hover tooltip. Wraps children in a span and positions a floating label.
|
|
7
|
+
* @param {string} text - Tooltip content
|
|
8
|
+
* @param {string} side - 'top' | 'bottom' (default: 'top')
|
|
9
|
+
* @param {number} delay - Show delay in ms (default: 400)
|
|
10
|
+
*/
|
|
11
|
+
export default function Tooltip({ text, side = 'top', delay = 400, children }) {
|
|
12
|
+
const [visible, setVisible] = useState(false);
|
|
13
|
+
const [pos, setPos] = useState({ x: 0, y: 0 });
|
|
14
|
+
const ref = useRef(null);
|
|
15
|
+
const tipRef = useRef(null);
|
|
16
|
+
const timer = useRef(null);
|
|
17
|
+
|
|
18
|
+
function show() {
|
|
19
|
+
timer.current = setTimeout(() => {
|
|
20
|
+
if (!ref.current) return;
|
|
21
|
+
const rect = ref.current.getBoundingClientRect();
|
|
22
|
+
const x = rect.left + rect.width / 2;
|
|
23
|
+
const y = side === 'bottom' ? rect.bottom + 6 : rect.top - 6;
|
|
24
|
+
setPos({ x, y });
|
|
25
|
+
setVisible(true);
|
|
26
|
+
}, delay);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function hide() {
|
|
30
|
+
clearTimeout(timer.current);
|
|
31
|
+
setVisible(false);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
useEffect(() => () => clearTimeout(timer.current), []);
|
|
35
|
+
|
|
36
|
+
const transform = side === 'bottom'
|
|
37
|
+
? 'translate(-50%, 0)'
|
|
38
|
+
: 'translate(-50%, -100%)';
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<span ref={ref} className="ui-tooltip-trigger" onMouseEnter={show} onMouseLeave={hide} onFocus={show} onBlur={hide}>
|
|
42
|
+
{children}
|
|
43
|
+
{visible && text && (
|
|
44
|
+
<span
|
|
45
|
+
ref={tipRef}
|
|
46
|
+
className={`ui-tooltip ui-tooltip--${side}`}
|
|
47
|
+
style={{ left: pos.x, top: pos.y, transform }}
|
|
48
|
+
role="tooltip"
|
|
49
|
+
>
|
|
50
|
+
{text}
|
|
51
|
+
</span>
|
|
52
|
+
)}
|
|
53
|
+
</span>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
top: calc(100% + 6px);
|
|
48
48
|
right: 0;
|
|
49
49
|
min-width: 180px;
|
|
50
|
-
background:
|
|
50
|
+
background: var(--ui-glass);
|
|
51
51
|
backdrop-filter: blur(var(--ui-glass-blur-heavy));
|
|
52
52
|
border: 1px solid var(--ui-border);
|
|
53
53
|
border-top-color: var(--ui-border-strong);
|
|
@@ -97,6 +97,23 @@
|
|
|
97
97
|
color: var(--ui-danger);
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/* Theme toggle row */
|
|
101
|
+
.ui-user-menu-theme {
|
|
102
|
+
display: flex;
|
|
103
|
+
align-items: center;
|
|
104
|
+
gap: 0.5rem;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.ui-user-menu-theme-icon {
|
|
108
|
+
display: inline-flex;
|
|
109
|
+
color: var(--ui-text-subtle);
|
|
110
|
+
transition: color 0.15s;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.ui-user-menu-theme:hover .ui-user-menu-theme-icon {
|
|
114
|
+
color: var(--ui-accent);
|
|
115
|
+
}
|
|
116
|
+
|
|
100
117
|
/* Separator */
|
|
101
118
|
.ui-user-menu-sep {
|
|
102
119
|
height: 1px;
|
|
@@ -1,35 +1,26 @@
|
|
|
1
1
|
/** Contract: contracts/packages-ui-components/rules.md */
|
|
2
|
-
import { useState,
|
|
2
|
+
import { useState, useRef, useCallback } from 'react';
|
|
3
|
+
import useClickOutside from '../hooks/useClickOutside.js';
|
|
4
|
+
import useTheme from '../hooks/useTheme.js';
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* User avatar menu with glassmorphic dropdown.
|
|
6
8
|
* Trigger shows avatar image or first-letter initials.
|
|
7
|
-
* Dropdown shows user name, app-specific items, and a pinned logout action.
|
|
9
|
+
* Dropdown shows user name, theme toggle, app-specific items, and a pinned logout action.
|
|
8
10
|
*
|
|
9
11
|
* @param {string} name - User display name
|
|
10
12
|
* @param {string} [avatar] - Optional avatar URL (falls back to initials)
|
|
11
13
|
* @param {Array} items - [{ label, action }] app-specific menu items
|
|
12
14
|
* @param {function} onLogout - Always rendered last with separator + danger style
|
|
15
|
+
* @param {boolean} [showThemeToggle=true] - Show dark/light theme toggle in dropdown
|
|
13
16
|
*/
|
|
14
|
-
export default function UserMenu({ name, avatar, items = [], onLogout }) {
|
|
17
|
+
export default function UserMenu({ name, avatar, items = [], onLogout, showThemeToggle = true }) {
|
|
15
18
|
const [open, setOpen] = useState(false);
|
|
16
19
|
const ref = useRef(null);
|
|
17
20
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
if (ref.current && !ref.current.contains(e.target)) setOpen(false);
|
|
22
|
-
}
|
|
23
|
-
function onKey(e) {
|
|
24
|
-
if (e.key === 'Escape') setOpen(false);
|
|
25
|
-
}
|
|
26
|
-
document.addEventListener('mousedown', onMouseDown);
|
|
27
|
-
document.addEventListener('keydown', onKey);
|
|
28
|
-
return () => {
|
|
29
|
-
document.removeEventListener('mousedown', onMouseDown);
|
|
30
|
-
document.removeEventListener('keydown', onKey);
|
|
31
|
-
};
|
|
32
|
-
}, [open]);
|
|
21
|
+
const close = useCallback(() => setOpen(false), []);
|
|
22
|
+
useClickOutside(ref, close, open);
|
|
23
|
+
const { resolvedTheme, toggle: toggleTheme } = useTheme();
|
|
33
24
|
|
|
34
25
|
const initials = name ? name.charAt(0).toUpperCase() : '?';
|
|
35
26
|
|
|
@@ -49,6 +40,25 @@ export default function UserMenu({ name, avatar, items = [], onLogout }) {
|
|
|
49
40
|
{open && (
|
|
50
41
|
<div className="ui-user-menu-dropdown">
|
|
51
42
|
<div className="ui-user-menu-name">{name}</div>
|
|
43
|
+
{showThemeToggle && (
|
|
44
|
+
<button
|
|
45
|
+
className="ui-user-menu-item ui-user-menu-theme"
|
|
46
|
+
onClick={toggleTheme}
|
|
47
|
+
>
|
|
48
|
+
<span className="ui-user-menu-theme-icon">
|
|
49
|
+
{resolvedTheme === 'dark' ? (
|
|
50
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
51
|
+
<circle cx="12" cy="12" r="5" /><line x1="12" y1="1" x2="12" y2="3" /><line x1="12" y1="21" x2="12" y2="23" /><line x1="4.22" y1="4.22" x2="5.64" y2="5.64" /><line x1="18.36" y1="18.36" x2="19.78" y2="19.78" /><line x1="1" y1="12" x2="3" y2="12" /><line x1="21" y1="12" x2="23" y2="12" /><line x1="4.22" y1="19.78" x2="5.64" y2="18.36" /><line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
|
|
52
|
+
</svg>
|
|
53
|
+
) : (
|
|
54
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
55
|
+
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
|
56
|
+
</svg>
|
|
57
|
+
)}
|
|
58
|
+
</span>
|
|
59
|
+
{resolvedTheme === 'dark' ? 'Light mode' : 'Dark mode'}
|
|
60
|
+
</button>
|
|
61
|
+
)}
|
|
52
62
|
<div className="ui-user-menu-sep" />
|
|
53
63
|
{items.map((item, i) => (
|
|
54
64
|
<button
|