@hexclave/dashboard-ui-components 1.0.9 → 1.0.11
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/dist/components/button.d.ts +4 -4
- package/dist/components/chart-legend.d.ts +1 -1
- package/dist/components/pill-toggle.d.ts.map +1 -1
- package/dist/components/pill-toggle.js +57 -8
- package/dist/components/pill-toggle.js.map +1 -1
- package/dist/components/tabs.d.ts.map +1 -1
- package/dist/components/tabs.js +71 -19
- package/dist/components/tabs.js.map +1 -1
- package/dist/dashboard-ui-components.global.js +221 -98
- package/dist/dashboard-ui-components.global.js.map +3 -3
- package/dist/esm/components/chart-legend.d.ts +1 -1
- package/dist/esm/components/pill-toggle.d.ts.map +1 -1
- package/dist/esm/components/pill-toggle.js +58 -9
- package/dist/esm/components/pill-toggle.js.map +1 -1
- package/dist/esm/components/tabs.d.ts.map +1 -1
- package/dist/esm/components/tabs.js +72 -20
- package/dist/esm/components/tabs.js.map +1 -1
- package/package.json +3 -3
- package/src/components/pill-toggle.tsx +148 -78
- package/src/components/tabs.tsx +83 -10
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useState } from "react";
|
|
4
|
-
import { cn, Spinner, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@hexclave/ui";
|
|
5
|
-
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { cn, Spinner, Tooltip, TooltipContent, TooltipPortal, TooltipProvider, TooltipTrigger } from "@hexclave/ui";
|
|
6
5
|
import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
|
|
7
6
|
import { useGlassmorphicDefault } from "./card";
|
|
8
7
|
|
|
@@ -29,13 +28,21 @@ export type DesignPillToggleProps = {
|
|
|
29
28
|
|
|
30
29
|
type SizeClass = {
|
|
31
30
|
button: string,
|
|
31
|
+
iconOnlyButton: string,
|
|
32
32
|
icon: string,
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
+
type SliderMetrics = {
|
|
36
|
+
left: number,
|
|
37
|
+
width: number,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const sliderTransition = "transform 200ms ease-out, width 200ms ease-out";
|
|
41
|
+
|
|
35
42
|
const sizeClasses = new Map<DesignPillToggleSize, SizeClass>([
|
|
36
|
-
["sm", { button: "px-3 py-1.5 text-xs", icon: "h-3.5 w-3.5" }],
|
|
37
|
-
["md", { button: "px-4 py-2 text-sm", icon: "h-4 w-4" }],
|
|
38
|
-
["lg", { button: "px-5 py-2.5 text-sm", icon: "h-4 w-4" }],
|
|
43
|
+
["sm", { button: "px-3 py-1.5 text-xs", iconOnlyButton: "h-7 w-7 text-xs", icon: "h-3.5 w-3.5" }],
|
|
44
|
+
["md", { button: "px-4 py-2 text-sm", iconOnlyButton: "h-9 w-9 text-sm", icon: "h-4 w-4" }],
|
|
45
|
+
["lg", { button: "px-5 py-2.5 text-sm", iconOnlyButton: "h-10 w-10 text-sm", icon: "h-4 w-4" }],
|
|
39
46
|
]);
|
|
40
47
|
|
|
41
48
|
const gradientClasses = new Map<DesignPillToggleGradient, string>([
|
|
@@ -70,6 +77,10 @@ export function DesignPillToggle({
|
|
|
70
77
|
const activeRingClass = getMapValueOrThrow(gradientClasses, gradient, "gradientClasses");
|
|
71
78
|
|
|
72
79
|
const [loadingOptionId, setLoadingOptionId] = useState<string | null>(null);
|
|
80
|
+
const [sliderMetrics, setSliderMetrics] = useState<SliderMetrics | null>(null);
|
|
81
|
+
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
|
|
82
|
+
const toggleRef = useRef<HTMLDivElement | null>(null);
|
|
83
|
+
const optionRefs = useRef(new Map<string, HTMLButtonElement>());
|
|
73
84
|
|
|
74
85
|
const handleClick = (optionId: string) => {
|
|
75
86
|
const result = onSelect(optionId);
|
|
@@ -81,79 +92,138 @@ export function DesignPillToggle({
|
|
|
81
92
|
}
|
|
82
93
|
};
|
|
83
94
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
|
|
97
|
+
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
98
|
+
const updatePrefersReducedMotion = () => setPrefersReducedMotion(mediaQuery.matches);
|
|
99
|
+
|
|
100
|
+
updatePrefersReducedMotion();
|
|
101
|
+
mediaQuery.addEventListener("change", updatePrefersReducedMotion);
|
|
102
|
+
|
|
103
|
+
return () => mediaQuery.removeEventListener("change", updatePrefersReducedMotion);
|
|
104
|
+
}, []);
|
|
105
|
+
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
const toggle = toggleRef.current;
|
|
108
|
+
const selectedButton = optionRefs.current.get(selected);
|
|
109
|
+
|
|
110
|
+
if (!toggle || !selectedButton) {
|
|
111
|
+
setSliderMetrics(null);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const updateSliderMetrics = () => {
|
|
116
|
+
setSliderMetrics({
|
|
117
|
+
left: selectedButton.offsetLeft,
|
|
118
|
+
width: selectedButton.offsetWidth,
|
|
119
|
+
});
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
updateSliderMetrics();
|
|
123
|
+
|
|
124
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
125
|
+
const resizeObserver = new ResizeObserver(updateSliderMetrics);
|
|
126
|
+
resizeObserver.observe(toggle);
|
|
127
|
+
resizeObserver.observe(selectedButton);
|
|
128
|
+
|
|
129
|
+
return () => resizeObserver.disconnect();
|
|
130
|
+
}, [options, selected]);
|
|
131
|
+
|
|
132
|
+
const body = (
|
|
133
|
+
<div
|
|
134
|
+
ref={toggleRef}
|
|
135
|
+
className={cn(
|
|
136
|
+
"relative inline-flex items-center gap-1 p-1 rounded-xl",
|
|
137
|
+
glassmorphic
|
|
138
|
+
? "bg-foreground/[0.04] backdrop-blur-sm"
|
|
139
|
+
: "bg-black/[0.08] dark:bg-white/[0.04]",
|
|
140
|
+
className
|
|
141
|
+
)}
|
|
142
|
+
>
|
|
143
|
+
{sliderMetrics != null && (
|
|
144
|
+
<div
|
|
145
|
+
className={cn(
|
|
146
|
+
"pointer-events-none absolute inset-y-1 left-0 z-0 rounded-lg bg-background shadow-sm ring-1",
|
|
147
|
+
glassmorphic
|
|
148
|
+
? "ring-foreground/[0.06] dark:bg-[hsl(240,71%,70%)]/10 dark:ring-[hsl(240,71%,70%)]/20"
|
|
149
|
+
: activeRingClass
|
|
150
|
+
)}
|
|
151
|
+
style={{
|
|
152
|
+
transition: prefersReducedMotion ? undefined : sliderTransition,
|
|
153
|
+
transform: `translateX(${sliderMetrics.left}px)`,
|
|
154
|
+
width: sliderMetrics.width,
|
|
155
|
+
}}
|
|
156
|
+
/>
|
|
157
|
+
)}
|
|
158
|
+
{options.map((option) => {
|
|
159
|
+
const isActive = selected === option.id;
|
|
160
|
+
const Icon = option.icon;
|
|
161
|
+
|
|
162
|
+
const pill = (
|
|
163
|
+
<button
|
|
164
|
+
type="button"
|
|
165
|
+
key={option.id}
|
|
166
|
+
ref={(element) => {
|
|
167
|
+
if (element) {
|
|
168
|
+
optionRefs.current.set(option.id, element);
|
|
169
|
+
} else {
|
|
170
|
+
optionRefs.current.delete(option.id);
|
|
171
|
+
}
|
|
172
|
+
}}
|
|
173
|
+
onClick={() => handleClick(option.id)}
|
|
174
|
+
disabled={loadingOptionId !== null}
|
|
175
|
+
aria-label={showLabels ? undefined : option.label}
|
|
176
|
+
className={cn(
|
|
177
|
+
"relative z-10 flex items-center gap-2 font-medium rounded-lg transition-all duration-150 hover:transition-none",
|
|
178
|
+
showLabels ? sizeClass.button : sizeClass.iconOnlyButton,
|
|
179
|
+
!showLabels && "justify-center p-0",
|
|
180
|
+
isActive
|
|
181
|
+
? cn("text-foreground", glassmorphic && "dark:text-[hsl(240,71%,90%)]")
|
|
182
|
+
: cn(
|
|
183
|
+
"text-muted-foreground hover:text-foreground",
|
|
184
|
+
glassmorphic
|
|
185
|
+
? "hover:bg-background/50"
|
|
186
|
+
: "hover:bg-black/[0.06] dark:hover:bg-white/[0.04]"
|
|
187
|
+
)
|
|
188
|
+
)}
|
|
189
|
+
>
|
|
190
|
+
{loadingOptionId === option.id && (
|
|
191
|
+
<Spinner
|
|
192
|
+
size={12}
|
|
193
|
+
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none"
|
|
194
|
+
/>
|
|
195
|
+
)}
|
|
196
|
+
<span className={cn(
|
|
197
|
+
"flex items-center gap-2",
|
|
198
|
+
loadingOptionId === option.id && "invisible"
|
|
199
|
+
)}>
|
|
200
|
+
{Icon && <Icon className={sizeClass.icon} />}
|
|
201
|
+
{showLabels && option.label}
|
|
202
|
+
</span>
|
|
203
|
+
</button>
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
if (!showLabels) {
|
|
207
|
+
return (
|
|
208
|
+
<Tooltip key={option.id} delayDuration={0}>
|
|
209
|
+
<TooltipTrigger asChild>
|
|
210
|
+
{pill}
|
|
211
|
+
</TooltipTrigger>
|
|
212
|
+
<TooltipPortal>
|
|
213
|
+
<TooltipContent side="top">
|
|
214
|
+
{option.label}
|
|
215
|
+
</TooltipContent>
|
|
216
|
+
</TooltipPortal>
|
|
217
|
+
</Tooltip>
|
|
137
218
|
);
|
|
219
|
+
}
|
|
138
220
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
<TooltipTrigger asChild>
|
|
143
|
-
{pill}
|
|
144
|
-
</TooltipTrigger>
|
|
145
|
-
<TooltipPortal>
|
|
146
|
-
<TooltipContent side="top">
|
|
147
|
-
{option.label}
|
|
148
|
-
</TooltipContent>
|
|
149
|
-
</TooltipPortal>
|
|
150
|
-
</Tooltip>
|
|
151
|
-
);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
return pill;
|
|
155
|
-
})}
|
|
156
|
-
</div>
|
|
157
|
-
</TooltipProvider>
|
|
221
|
+
return pill;
|
|
222
|
+
})}
|
|
223
|
+
</div>
|
|
158
224
|
);
|
|
225
|
+
|
|
226
|
+
// Tooltips require a TooltipProvider in scope. Wrap defensively so callers
|
|
227
|
+
// outside an existing provider (e.g. inside a PageLayout actions slot) work.
|
|
228
|
+
return showLabels ? body : <TooltipProvider>{body}</TooltipProvider>;
|
|
159
229
|
}
|
package/src/components/tabs.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useState, type ReactNode } from "react";
|
|
3
|
+
import { useEffect, useRef, useState, type ReactNode } from "react";
|
|
4
4
|
import { cn, Spinner } from "@hexclave/ui";
|
|
5
5
|
import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
|
|
6
6
|
import { useGlassmorphicDefault } from "./card";
|
|
@@ -39,6 +39,13 @@ type GradientClass = {
|
|
|
39
39
|
underline: string,
|
|
40
40
|
};
|
|
41
41
|
|
|
42
|
+
type SliderMetrics = {
|
|
43
|
+
left: number,
|
|
44
|
+
width: number,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const sliderTransition = "transform 200ms ease-out, width 200ms ease-out";
|
|
48
|
+
|
|
42
49
|
const tabSizeClasses = new Map<DesignTabsSize, TabSizeClass>([
|
|
43
50
|
["sm", { button: "px-3 py-2 text-xs", badge: "text-[10px] px-1.5 py-0.5" }],
|
|
44
51
|
["md", { button: "px-4 py-3 text-sm", badge: "text-xs px-1.5 py-0.5" }],
|
|
@@ -119,6 +126,10 @@ export function DesignCategoryTabs({
|
|
|
119
126
|
const sizeClass = getMapValueOrThrow(tabSizeClasses, size, "tabSizeClasses");
|
|
120
127
|
const gradientClass = getMapValueOrThrow(gradientClasses, gradient, "gradientClasses");
|
|
121
128
|
const [loadingCategoryId, setLoadingCategoryId] = useState<string | null>(null);
|
|
129
|
+
const [sliderMetrics, setSliderMetrics] = useState<SliderMetrics | null>(null);
|
|
130
|
+
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
|
|
131
|
+
const tabListRef = useRef<HTMLDivElement | null>(null);
|
|
132
|
+
const tabButtonRefs = useRef(new Map<string, HTMLButtonElement>());
|
|
122
133
|
|
|
123
134
|
const handleSelect = (categoryId: string) => {
|
|
124
135
|
const result = onSelect(categoryId);
|
|
@@ -130,6 +141,43 @@ export function DesignCategoryTabs({
|
|
|
130
141
|
}
|
|
131
142
|
};
|
|
132
143
|
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
|
|
146
|
+
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
147
|
+
const updatePrefersReducedMotion = () => setPrefersReducedMotion(mediaQuery.matches);
|
|
148
|
+
|
|
149
|
+
updatePrefersReducedMotion();
|
|
150
|
+
mediaQuery.addEventListener("change", updatePrefersReducedMotion);
|
|
151
|
+
|
|
152
|
+
return () => mediaQuery.removeEventListener("change", updatePrefersReducedMotion);
|
|
153
|
+
}, []);
|
|
154
|
+
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
const tabList = tabListRef.current;
|
|
157
|
+
const selectedButton = tabButtonRefs.current.get(selectedCategory);
|
|
158
|
+
|
|
159
|
+
if (!tabList || !selectedButton) {
|
|
160
|
+
setSliderMetrics(null);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const updateSliderMetrics = () => {
|
|
165
|
+
setSliderMetrics({
|
|
166
|
+
left: selectedButton.offsetLeft,
|
|
167
|
+
width: selectedButton.offsetWidth,
|
|
168
|
+
});
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
updateSliderMetrics();
|
|
172
|
+
|
|
173
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
174
|
+
const resizeObserver = new ResizeObserver(updateSliderMetrics);
|
|
175
|
+
resizeObserver.observe(tabList);
|
|
176
|
+
resizeObserver.observe(selectedButton);
|
|
177
|
+
|
|
178
|
+
return () => resizeObserver.disconnect();
|
|
179
|
+
}, [categories, selectedCategory]);
|
|
180
|
+
|
|
133
181
|
return (
|
|
134
182
|
<div
|
|
135
183
|
className={cn(
|
|
@@ -142,10 +190,34 @@ export function DesignCategoryTabs({
|
|
|
142
190
|
{...props}
|
|
143
191
|
>
|
|
144
192
|
<div
|
|
193
|
+
ref={tabListRef}
|
|
145
194
|
className={cn(
|
|
146
|
-
"flex min-h-0 min-w-0 items-center gap-1 overflow-x-auto flex-nowrap [&::-webkit-scrollbar]:hidden",
|
|
195
|
+
"relative flex min-h-0 min-w-0 items-center gap-1 overflow-x-auto flex-nowrap [&::-webkit-scrollbar]:hidden",
|
|
147
196
|
)}
|
|
148
197
|
>
|
|
198
|
+
{glassmorphic && sliderMetrics != null && (
|
|
199
|
+
<div
|
|
200
|
+
className="pointer-events-none absolute inset-y-0 left-0 z-0 rounded-lg bg-background shadow-sm ring-1 ring-black/[0.12] motion-reduce:transition-none dark:ring-white/[0.06]"
|
|
201
|
+
style={{
|
|
202
|
+
transition: prefersReducedMotion ? undefined : sliderTransition,
|
|
203
|
+
transform: `translateX(${sliderMetrics.left}px)`,
|
|
204
|
+
width: sliderMetrics.width,
|
|
205
|
+
}}
|
|
206
|
+
/>
|
|
207
|
+
)}
|
|
208
|
+
{!glassmorphic && sliderMetrics != null && (
|
|
209
|
+
<div
|
|
210
|
+
className={cn(
|
|
211
|
+
"pointer-events-none absolute bottom-0 left-0 h-0.5 motion-reduce:transition-none",
|
|
212
|
+
gradientClass.underline
|
|
213
|
+
)}
|
|
214
|
+
style={{
|
|
215
|
+
transition: prefersReducedMotion ? undefined : sliderTransition,
|
|
216
|
+
transform: `translateX(${sliderMetrics.left}px)`,
|
|
217
|
+
width: sliderMetrics.width,
|
|
218
|
+
}}
|
|
219
|
+
/>
|
|
220
|
+
)}
|
|
149
221
|
{categories.map((category) => {
|
|
150
222
|
const isActive = selectedCategory === category.id;
|
|
151
223
|
const badgeValue = category.badgeCount ?? category.count;
|
|
@@ -154,18 +226,22 @@ export function DesignCategoryTabs({
|
|
|
154
226
|
return (
|
|
155
227
|
<button
|
|
156
228
|
key={category.id}
|
|
229
|
+
ref={(element) => {
|
|
230
|
+
if (element) {
|
|
231
|
+
tabButtonRefs.current.set(category.id, element);
|
|
232
|
+
} else {
|
|
233
|
+
tabButtonRefs.current.delete(category.id);
|
|
234
|
+
}
|
|
235
|
+
}}
|
|
157
236
|
onClick={() => handleSelect(category.id)}
|
|
158
237
|
disabled={loadingCategoryId !== null}
|
|
159
238
|
className={cn(
|
|
160
|
-
"font-medium transition-all duration-150 hover:transition-none relative flex flex-shrink-0 items-center justify-center gap-2 whitespace-nowrap",
|
|
239
|
+
"font-medium transition-all duration-150 hover:transition-none relative z-10 flex flex-shrink-0 items-center justify-center gap-2 whitespace-nowrap",
|
|
161
240
|
"hover:text-gray-900 dark:hover:text-gray-100",
|
|
162
241
|
sizeClass.button,
|
|
163
242
|
glassmorphic ? "rounded-lg" : "",
|
|
164
243
|
isActive
|
|
165
|
-
?
|
|
166
|
-
gradientClass.activeText,
|
|
167
|
-
glassmorphic && "bg-white shadow-sm ring-1 ring-black/[0.12] dark:bg-background dark:ring-white/[0.06]"
|
|
168
|
-
)
|
|
244
|
+
? gradientClass.activeText
|
|
169
245
|
: cn(
|
|
170
246
|
"text-gray-700 dark:text-gray-400",
|
|
171
247
|
glassmorphic && "rounded-lg hover:bg-white/50 dark:hover:bg-white/[0.06]",
|
|
@@ -200,9 +276,6 @@ export function DesignCategoryTabs({
|
|
|
200
276
|
</span>
|
|
201
277
|
)}
|
|
202
278
|
</span>
|
|
203
|
-
{!glassmorphic && isActive && (
|
|
204
|
-
<div className={cn("absolute bottom-0 left-0 right-0 h-0.5", gradientClass.underline)} />
|
|
205
|
-
)}
|
|
206
279
|
</button>
|
|
207
280
|
);
|
|
208
281
|
})}
|