@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.
@@ -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
- return (
85
- <TooltipProvider>
86
- <div
87
- className={cn(
88
- "inline-flex items-center gap-1 p-1 rounded-xl",
89
- glassmorphic
90
- ? "bg-foreground/[0.04] backdrop-blur-sm"
91
- : "bg-black/[0.08] dark:bg-white/[0.04]",
92
- className
93
- )}
94
- >
95
- {options.map((option) => {
96
- const isActive = selected === option.id;
97
- const Icon = option.icon;
98
-
99
- const pill = (
100
- <button
101
- type="button"
102
- key={option.id}
103
- onClick={() => handleClick(option.id)}
104
- disabled={loadingOptionId !== null}
105
- className={cn(
106
- "relative flex items-center gap-2 font-medium rounded-lg transition-all duration-150 hover:transition-none",
107
- sizeClass.button,
108
- isActive
109
- ? cn(
110
- "bg-white dark:bg-background text-foreground shadow-sm ring-1",
111
- glassmorphic
112
- ? "ring-foreground/[0.06] dark:bg-[hsl(240,71%,70%)]/10 dark:text-[hsl(240,71%,90%)] dark:ring-[hsl(240,71%,70%)]/20"
113
- : activeRingClass
114
- )
115
- : cn(
116
- "text-muted-foreground hover:text-foreground",
117
- glassmorphic
118
- ? "hover:bg-background/50"
119
- : "hover:bg-black/[0.06] dark:hover:bg-white/[0.04]"
120
- )
121
- )}
122
- >
123
- {loadingOptionId === option.id && (
124
- <Spinner
125
- size={12}
126
- className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none"
127
- />
128
- )}
129
- <span className={cn(
130
- "flex items-center gap-2",
131
- loadingOptionId === option.id && "invisible"
132
- )}>
133
- {Icon && <Icon className={sizeClass.icon} />}
134
- {showLabels && option.label}
135
- </span>
136
- </button>
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
- if (!showLabels) {
140
- return (
141
- <Tooltip key={option.id} delayDuration={0}>
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
  }
@@ -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
- ? cn(
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
  })}