@hunterchen/canvas 0.7.0 → 0.8.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.
@@ -1,6 +1,12 @@
1
1
  import { useState, useEffect } from "react";
2
2
  import * as LucideIcons from "lucide-react";
3
3
  import { AnimatePresence, motion } from "framer-motion";
4
+ import type {
5
+ NavbarDisplayMode,
6
+ NavbarButtonConfig,
7
+ NavbarTooltipConfig,
8
+ } from "../../../types";
9
+ import { cn } from "../../../lib/utils";
4
10
 
5
11
  interface SingleButtonProps {
6
12
  label: string;
@@ -10,6 +16,14 @@ interface SingleButtonProps {
10
16
  isPushed: boolean;
11
17
  link?: string;
12
18
  onDebouncedClick?: (callback: () => void) => void;
19
+ /** Display mode for the button */
20
+ displayMode?: NavbarDisplayMode;
21
+ /** Button styling configuration */
22
+ buttonConfig?: NavbarButtonConfig;
23
+ /** Tooltip configuration */
24
+ tooltipConfig?: NavbarTooltipConfig;
25
+ /** Whether the navbar is in vertical layout */
26
+ isVertical?: boolean;
13
27
  }
14
28
 
15
29
  export default function SingleButton({
@@ -19,17 +33,48 @@ export default function SingleButton({
19
33
  isPushed,
20
34
  link,
21
35
  onDebouncedClick,
36
+ displayMode = "icons",
37
+ buttonConfig = {},
38
+ tooltipConfig = {},
39
+ isVertical = false,
22
40
  }: SingleButtonProps) {
23
41
  const [isHovered, setIsHovered] = useState(false);
24
42
  const [showTag, setShowTag] = useState(false);
25
43
  const [copiedEmail, setCopiedEmail] = useState(false);
44
+
26
45
  const isLucideIconName = typeof icon === "string";
27
46
  const IconComponent = isLucideIconName
28
47
  ? (LucideIcons[icon as keyof typeof LucideIcons] as LucideIcons.LucideIcon | undefined)
29
48
  : icon;
30
- const TagDelay = 100;
31
49
 
32
- if (!IconComponent) {
50
+ // Extract config values
51
+ const {
52
+ className: buttonClassName,
53
+ style: buttonStyle,
54
+ activeClassName,
55
+ activeStyle,
56
+ hoverClassName,
57
+ hoverStyle,
58
+ iconClassName,
59
+ iconSize = 20,
60
+ labelClassName,
61
+ labelStyle,
62
+ } = buttonConfig;
63
+
64
+ const {
65
+ disabled: tooltipDisabled = false,
66
+ className: tooltipClassName,
67
+ style: tooltipStyle,
68
+ delay: tooltipDelay = 100,
69
+ } = tooltipConfig;
70
+
71
+ // Determine what to show based on display mode
72
+ const showIcon = displayMode !== "labels";
73
+ const allowExpand = displayMode === "icons"; // Only expand on active in icons mode
74
+ const showTooltip = (displayMode === "icons" || displayMode === "compact") && !tooltipDisabled;
75
+
76
+ // Validate icon component for modes that need it
77
+ if (showIcon && !IconComponent) {
33
78
  throw new Error(
34
79
  "A valid 'icon' prop is required (Lucide icon name or custom icon component).",
35
80
  );
@@ -38,10 +83,10 @@ export default function SingleButton({
38
83
  useEffect(() => {
39
84
  let timeoutId: NodeJS.Timeout;
40
85
 
41
- if (isHovered) {
86
+ if (isHovered && showTooltip) {
42
87
  timeoutId = setTimeout(() => {
43
88
  setShowTag(true);
44
- }, TagDelay);
89
+ }, tooltipDelay);
45
90
  } else {
46
91
  setShowTag(false);
47
92
  }
@@ -49,7 +94,7 @@ export default function SingleButton({
49
94
  return () => {
50
95
  clearTimeout(timeoutId);
51
96
  };
52
- }, [isHovered]);
97
+ }, [isHovered, showTooltip, tooltipDelay]);
53
98
 
54
99
  useEffect(() => {
55
100
  setShowTag(false);
@@ -85,11 +130,168 @@ export default function SingleButton({
85
130
 
86
131
  const displayLabel = copiedEmail ? "Email copied!" : label;
87
132
 
133
+ // Compute button classes
134
+ const baseButtonClass = "relative flex items-center rounded-md p-2 text-neutral-500 transition-colors duration-200 focus:outline-none";
135
+ // Only apply default classes if no custom style is provided
136
+ const stateClass = isPushed
137
+ ? (activeClassName || (!activeStyle && "bg-neutral-200"))
138
+ : isHovered
139
+ ? (hoverClassName || (!hoverStyle && "bg-neutral-100"))
140
+ : "";
141
+
142
+ // Compute button styles
143
+ const computedButtonStyle: React.CSSProperties = {
144
+ ...buttonStyle,
145
+ ...(isPushed && activeStyle),
146
+ ...(isHovered && !isPushed && hoverStyle),
147
+ };
148
+
149
+ // Compute icon classes and styles
150
+ const iconSizeStyle = { width: iconSize, height: iconSize };
151
+ const baseIconClass = "flex-shrink-0";
152
+ // Only apply default icon colors if no custom button color style is provided
153
+ const hasCustomColor = buttonStyle?.color;
154
+ const iconColorClass = hasCustomColor
155
+ ? ""
156
+ : isPushed
157
+ ? "text-neutral-700"
158
+ : "text-neutral-500";
159
+
160
+ // Compute label classes
161
+ const baseLabelClass = "whitespace-nowrap font-canvas-figtree text-sm font-medium text-neutral-700";
162
+
163
+ // Tooltip position based on vertical layout
164
+ const tooltipPositionClass = isVertical
165
+ ? "left-full top-1/2 -translate-y-1/2 ml-2"
166
+ : "-top-10 left-1/2";
167
+ const tooltipTransform = isVertical
168
+ ? { x: 0, y: "-50%" }
169
+ : { x: "-50%" };
170
+
171
+ // Render icon element
172
+ const renderIcon = () => {
173
+ if (!showIcon || !IconComponent) return null;
174
+ return (
175
+ <IconComponent
176
+ className={cn(baseIconClass, iconColorClass, iconClassName)}
177
+ style={iconSizeStyle}
178
+ />
179
+ );
180
+ };
181
+
182
+ // Render label element
183
+ const renderLabel = (animated = false) => {
184
+ if (animated) {
185
+ return (
186
+ <motion.span
187
+ initial={{ opacity: 0, width: 0 }}
188
+ animate={{ opacity: 1, width: "auto" }}
189
+ exit={{ opacity: 0, width: 0 }}
190
+ transition={{
191
+ duration: 0.1,
192
+ ease: "easeInOut",
193
+ }}
194
+ className={cn("overflow-hidden", baseLabelClass, labelClassName)}
195
+ style={labelStyle}
196
+ >
197
+ {displayLabel}
198
+ </motion.span>
199
+ );
200
+ }
201
+ return (
202
+ <span
203
+ className={cn(baseLabelClass, labelClassName)}
204
+ style={labelStyle}
205
+ >
206
+ {displayLabel}
207
+ </span>
208
+ );
209
+ };
210
+
211
+ // Render tooltip
212
+ const renderTooltip = () => {
213
+ if (!showTooltip || !showTag || isPushed) return null;
214
+
215
+ return (
216
+ <AnimatePresence>
217
+ <motion.div
218
+ initial={{ opacity: 0, y: isVertical ? 0 : 5, scale: 0.9, ...tooltipTransform }}
219
+ animate={{ opacity: 1, y: 0, scale: 1, ...tooltipTransform }}
220
+ exit={{ opacity: 0, y: isVertical ? 0 : 5, scale: 0.9, ...tooltipTransform }}
221
+ transition={{
222
+ duration: 0.05,
223
+ ease: "easeOut",
224
+ }}
225
+ className={cn("pointer-events-none absolute z-50", tooltipPositionClass)}
226
+ >
227
+ <div className="rounded-sm bg-gradient-to-t from-black/10 to-transparent px-[1px] pb-[2.5px] pt-[1px]">
228
+ <div
229
+ className={cn(
230
+ "whitespace-nowrap rounded-sm px-2 py-1 font-canvas-figtree text-sm",
231
+ !tooltipStyle?.backgroundColor && "bg-neutral-50",
232
+ !tooltipStyle?.color && "text-neutral-600",
233
+ tooltipClassName,
234
+ )}
235
+ style={tooltipStyle}
236
+ >
237
+ {displayLabel}
238
+ </div>
239
+ </div>
240
+ </motion.div>
241
+ </AnimatePresence>
242
+ );
243
+ };
244
+
245
+ // Render based on display mode
246
+ const renderContent = () => {
247
+ // Labels only mode
248
+ if (displayMode === "labels") {
249
+ return renderLabel();
250
+ }
251
+
252
+ // Icons + labels always mode
253
+ if (displayMode === "icons-labels") {
254
+ return (
255
+ <div className="flex items-center gap-2">
256
+ {renderIcon()}
257
+ {renderLabel()}
258
+ </div>
259
+ );
260
+ }
261
+
262
+ // Compact mode - icons only, no expansion
263
+ if (displayMode === "compact") {
264
+ return (
265
+ <>
266
+ {renderIcon()}
267
+ {renderTooltip()}
268
+ </>
269
+ );
270
+ }
271
+
272
+ // Icons mode (default) - expands on active
273
+ if (isPushed && allowExpand) {
274
+ return (
275
+ <div className="flex items-center gap-2">
276
+ <div>{renderIcon()}</div>
277
+ {renderLabel(true)}
278
+ </div>
279
+ );
280
+ }
281
+
282
+ return (
283
+ <>
284
+ {renderIcon()}
285
+ {renderTooltip()}
286
+ </>
287
+ );
288
+ };
289
+
88
290
  return (
89
291
  <motion.button
90
292
  aria-label={label}
91
- className={`relative flex items-center rounded-md p-2 text-canvas-medium transition-colors duration-200 ${isPushed ? "bg-[#EEE2FB]" : isHovered ? "bg-canvas-highlight" : ""
92
- }`}
293
+ className={cn(baseButtonClass, stateClass, buttonClassName)}
294
+ style={computedButtonStyle}
93
295
  onClick={handleClick}
94
296
  onMouseEnter={() => setIsHovered(true)}
95
297
  onMouseLeave={() => setIsHovered(false)}
@@ -100,55 +302,7 @@ export default function SingleButton({
100
302
  damping: 25,
101
303
  }}
102
304
  >
103
- {isPushed ? (
104
- <div className="flex items-center gap-2">
105
- <div>
106
- <IconComponent
107
- className={`h-5 w-5 flex-shrink-0 ${isPushed ? (isLucideIconName ? "text-canvas-emphasis" : "text-white") : "text-canvas-medium"
108
- }`}
109
- />
110
- </div>
111
- <motion.span
112
- initial={{ opacity: 0, width: 0 }}
113
- animate={{ opacity: 1, width: "auto" }}
114
- exit={{ opacity: 0, width: 0 }}
115
- transition={{
116
- duration: 0.1,
117
- ease: "easeInOut",
118
- }}
119
- className="overflow-hidden whitespace-nowrap font-canvas-figtree text-sm font-medium text-canvas-emphasis"
120
- >
121
- {displayLabel}
122
- </motion.span>
123
- </div>
124
- ) : (
125
- <div>
126
- <IconComponent
127
- className={`h-5 w-5 flex-shrink-0 ${isPushed ? (isLucideIconName ? "text-canvas-emphasis" : "text-white") : "text-canvas-medium"
128
- }`}
129
- />
130
- <AnimatePresence>
131
- {showTag && !isPushed && (
132
- <motion.div
133
- initial={{ opacity: 0, y: 5, scale: 0.9, x: "-50%" }}
134
- animate={{ opacity: 1, y: 0, scale: 1, x: "-50%" }}
135
- exit={{ opacity: 0, y: 5, scale: 0.9, x: "-50%" }}
136
- transition={{
137
- duration: 0.05,
138
- ease: "easeOut",
139
- }}
140
- className="pointer-events-none absolute -top-10 left-1/2 z-50"
141
- >
142
- <div className="rounded-sm bg-gradient-to-t from-black/10 to-transparent px-[1px] pb-[2.5px] pt-[1px]">
143
- <div className="whitespace-nowrap rounded-sm bg-canvas-offwhite px-2 py-1 font-canvas-figtree text-sm text-canvas-medium">
144
- {displayLabel}
145
- </div>
146
- </div>
147
- </motion.div>
148
- )}
149
- </AnimatePresence>
150
- </div>
151
- )}
305
+ {renderContent()}
152
306
  </motion.button>
153
307
  );
154
308
  }
package/src/index.ts CHANGED
@@ -56,4 +56,9 @@ export type {
56
56
  ToolbarConfig,
57
57
  ToolbarPosition,
58
58
  ToolbarDisplayMode,
59
+ NavbarConfig,
60
+ NavbarPosition,
61
+ NavbarDisplayMode,
62
+ NavbarButtonConfig,
63
+ NavbarTooltipConfig,
59
64
  } from "./types";
package/src/styles.css CHANGED
@@ -4,19 +4,21 @@
4
4
 
5
5
  @layer base {
6
6
  :root {
7
- --canvas-beige: #f7f1e5;
8
- --canvas-coral: #ffb5a7;
9
- --canvas-lilac: #d9c8e6;
10
- --canvas-salmon: #ffa585;
11
- --canvas-heavy: #3c204c;
12
- --canvas-emphasis: #513b7a;
13
- --canvas-active: #8f57ad;
14
- --canvas-tinted: #c9a7db;
15
- --canvas-medium: #776780;
16
- --canvas-light: #c3b8cb;
17
- --canvas-faint-lilac: #f5f2f7;
18
- --canvas-offwhite: #fdfcfd;
19
- --canvas-highlight: #f5f2f7;
7
+ /* Neutral gray palette (default) */
8
+ --canvas-beige: #f5f5f5;
9
+ --canvas-coral: #d4d4d4;
10
+ --canvas-lilac: #e5e5e5;
11
+ --canvas-salmon: #a3a3a3;
12
+ --canvas-heavy: #171717;
13
+ --canvas-emphasis: #262626;
14
+ --canvas-active: #525252;
15
+ --canvas-tinted: #a3a3a3;
16
+ --canvas-medium: #737373;
17
+ --canvas-light: #a3a3a3;
18
+ --canvas-faint-lilac: #fafafa;
19
+ --canvas-offwhite: #ffffff;
20
+ --canvas-highlight: #f5f5f5;
21
+ --canvas-pushed: #e5e5e5;
20
22
  --canvas-border-light: 0 0% 89%;
21
23
  }
22
24
  }
@@ -97,3 +97,94 @@ export interface ToolbarConfig {
97
97
  /** Format for scale. Default: '1.00x' */
98
98
  scaleFormat?: (scale: number) => string;
99
99
  }
100
+
101
+ /**
102
+ * Preset positions for the navbar
103
+ */
104
+ export type NavbarPosition = 'top' | 'bottom' | 'left' | 'right';
105
+
106
+ /**
107
+ * Display modes for navbar items
108
+ */
109
+ export type NavbarDisplayMode =
110
+ | 'icons' // Icons only, label shows on expand (default)
111
+ | 'labels' // Labels only, no icons
112
+ | 'icons-labels' // Always show both icon and label
113
+ | 'compact'; // Icons only, no expansion - just highlight
114
+
115
+ /**
116
+ * Tooltip configuration for navbar buttons
117
+ */
118
+ export interface NavbarTooltipConfig {
119
+ /** Disable tooltips entirely. Default: false */
120
+ disabled?: boolean;
121
+ /** Additional className for tooltip */
122
+ className?: string;
123
+ /** Inline styles for tooltip */
124
+ style?: React.CSSProperties;
125
+ /** Delay before showing tooltip in ms. Default: 100 */
126
+ delay?: number;
127
+ }
128
+
129
+ /**
130
+ * Button styling configuration for navbar
131
+ */
132
+ export interface NavbarButtonConfig {
133
+ /** Additional className for all buttons */
134
+ className?: string;
135
+ /** Inline styles for all buttons */
136
+ style?: React.CSSProperties;
137
+ /** Active/pushed state className */
138
+ activeClassName?: string;
139
+ /** Active state inline styles */
140
+ activeStyle?: React.CSSProperties;
141
+ /** Hover state className */
142
+ hoverClassName?: string;
143
+ /** Hover state inline styles */
144
+ hoverStyle?: React.CSSProperties;
145
+ /** Icon className */
146
+ iconClassName?: string;
147
+ /** Icon size in pixels. Default: 20 */
148
+ iconSize?: number;
149
+ /** Label className */
150
+ labelClassName?: string;
151
+ /** Label inline styles */
152
+ labelStyle?: React.CSSProperties;
153
+ }
154
+
155
+ /**
156
+ * Configuration options for the canvas navbar
157
+ */
158
+ export interface NavbarConfig {
159
+ // === Visibility ===
160
+ /** Hide the navbar entirely. Default: false */
161
+ hidden?: boolean;
162
+
163
+ // === Display Mode ===
164
+ /** How to display items. Default: 'icons' */
165
+ display?: NavbarDisplayMode;
166
+
167
+ // === Positioning ===
168
+ /** Preset position. Default: 'bottom' */
169
+ position?: NavbarPosition;
170
+
171
+ // === Container Styling ===
172
+ /** Additional className for the navbar container */
173
+ className?: string;
174
+ /** Inline styles for the navbar container */
175
+ style?: React.CSSProperties;
176
+
177
+ // === Button Configuration ===
178
+ /** Button styling options */
179
+ buttonConfig?: NavbarButtonConfig;
180
+
181
+ // === Tooltip Configuration ===
182
+ /** Tooltip options */
183
+ tooltipConfig?: NavbarTooltipConfig;
184
+
185
+ // === Spacing ===
186
+ /** Gap between buttons in pixels. Default: 4 */
187
+ gap?: number;
188
+ /** Padding inside the navbar in pixels. Default: 4 */
189
+ padding?: number;
190
+ }