@stevederico/skateboard-ui 2.10.0 → 2.11.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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # CHANGELOG
2
2
 
3
+ 2.11.0
4
+
5
+ Fix DynamicIcon lazy loading
6
+ Remove barrel import lucide-react
7
+ Hoist LandingView static values
8
+ Add FeatureIcon emoji fallback
9
+
10
+ 2.10.1
11
+
12
+ Add Geist font files self-hosted
13
+ Add @font-face declarations
14
+
3
15
  2.10.0
4
16
 
5
17
  Fix landing page feature icons
@@ -1,28 +1,11 @@
1
- import React from "react";
2
- import * as LucideIcons from "lucide-react";
1
+ import React, { useState, useEffect } from "react";
3
2
  import { cn } from "../shadcn/lib/utils.js";
4
3
 
5
4
  /**
6
- * Render a Lucide icon by name string.
5
+ * Convert a kebab-case, snake_case, or space-separated string to PascalCase.
7
6
  *
8
- * Accepts kebab-case, snake_case, or PascalCase icon names and resolves
9
- * them to the matching lucide-react component. Supports className merging
10
- * via shadcn cn() utility.
11
- *
12
- * @param {Object} props
13
- * @param {string} props.name - Icon name (e.g. "home", "arrow-right", "Settings")
14
- * @param {number} [props.size=24] - Icon size in pixels
15
- * @param {string} [props.color='currentColor'] - Icon stroke color
16
- * @param {number} [props.strokeWidth=2] - Stroke width
17
- * @param {string} [props.className] - Additional CSS classes
18
- * @returns {JSX.Element|null} Rendered icon or null if name not found
19
- *
20
- * @example
21
- * import DynamicIcon from '@stevederico/skateboard-ui/DynamicIcon';
22
- *
23
- * <DynamicIcon name="home" size={24} />
24
- * <DynamicIcon name="arrow-right" size={20} color="red" />
25
- * <DynamicIcon name="settings" className="text-muted-foreground" />
7
+ * @param {string} str - Input string
8
+ * @returns {string} PascalCase version
26
9
  */
27
10
  function toPascalCase(str) {
28
11
  return str
@@ -31,28 +14,112 @@ function toPascalCase(str) {
31
14
  .join("");
32
15
  }
33
16
 
34
- function resolveIcon(name) {
35
- const candidates = [name, toPascalCase(name), name.charAt(0).toUpperCase() + name.slice(1)];
17
+ let iconsModule = null;
18
+ let iconsPromise = null;
19
+
20
+ /**
21
+ * Lazily load the lucide-react module on first use.
22
+ * Subsequent calls return the cached module immediately.
23
+ *
24
+ * @returns {Promise<Object>} The lucide-react module
25
+ */
26
+ function loadIcons() {
27
+ if (iconsModule) return Promise.resolve(iconsModule);
28
+ if (!iconsPromise) {
29
+ iconsPromise = import("lucide-react").then((mod) => {
30
+ iconsModule = mod;
31
+ return mod;
32
+ });
33
+ }
34
+ return iconsPromise;
35
+ }
36
+
37
+ /**
38
+ * Resolve icon name to a Lucide component from the cached module.
39
+ *
40
+ * @param {Object} mod - The lucide-react module
41
+ * @param {string} name - Icon name (kebab-case, snake_case, or PascalCase)
42
+ * @returns {React.ComponentType|null} Icon component or null
43
+ */
44
+ function resolveIcon(mod, name) {
45
+ if (!mod) return null;
46
+ const candidates = [
47
+ name,
48
+ toPascalCase(name),
49
+ name.charAt(0).toUpperCase() + name.slice(1),
50
+ ];
36
51
  for (const candidate of candidates) {
37
- if (LucideIcons[candidate]) return LucideIcons[candidate];
52
+ if (mod[candidate]) return mod[candidate];
38
53
  }
39
54
  return null;
40
55
  }
41
56
 
42
- const DynamicIcon = ({ name, size = 24, color = "currentColor", strokeWidth = 2, className, ...props }) => {
43
- const Icon = resolveIcon(name);
44
- if (!Icon) return null;
45
- return <Icon size={size} color={color} strokeWidth={strokeWidth} className={cn(className)} {...props} />;
46
- };
47
-
48
57
  /**
49
58
  * Check if a name string resolves to a valid Lucide icon.
59
+ * Returns false until the icon module has been loaded.
50
60
  *
51
61
  * @param {string} name - Icon name to check
52
62
  * @returns {boolean} True if name resolves to an icon
53
63
  */
54
64
  export function canResolveIcon(name) {
55
- return resolveIcon(name) !== null;
65
+ if (!iconsModule) return false;
66
+ return resolveIcon(iconsModule, name) !== null;
56
67
  }
57
68
 
69
+ /**
70
+ * Render a Lucide icon by name string with lazy loading.
71
+ *
72
+ * Loads lucide-react on first render via dynamic import, then caches the
73
+ * module for all subsequent uses. Shows nothing while loading (typically
74
+ * under 50ms from cache).
75
+ *
76
+ * @param {Object} props
77
+ * @param {string} props.name - Icon name (e.g. "home", "arrow-right", "Settings")
78
+ * @param {number} [props.size=24] - Icon size in pixels
79
+ * @param {string} [props.color='currentColor'] - Icon stroke color
80
+ * @param {number} [props.strokeWidth=2] - Stroke width
81
+ * @param {string} [props.className] - Additional CSS classes
82
+ * @returns {JSX.Element|null} Rendered icon or null if loading/not found
83
+ *
84
+ * @example
85
+ * import DynamicIcon from '@stevederico/skateboard-ui/DynamicIcon';
86
+ *
87
+ * <DynamicIcon name="home" size={24} />
88
+ * <DynamicIcon name="arrow-right" size={20} color="red" />
89
+ * <DynamicIcon name="settings" className="text-muted-foreground" />
90
+ */
91
+ const DynamicIcon = ({
92
+ name,
93
+ size = 24,
94
+ color = "currentColor",
95
+ strokeWidth = 2,
96
+ className,
97
+ ...props
98
+ }) => {
99
+ const [Icon, setIcon] = useState(() => {
100
+ if (iconsModule) return resolveIcon(iconsModule, name);
101
+ return null;
102
+ });
103
+
104
+ useEffect(() => {
105
+ if (Icon) return;
106
+ loadIcons().then((mod) => {
107
+ const resolved = resolveIcon(mod, name);
108
+ setIcon(() => resolved);
109
+ });
110
+ }, [name, Icon]);
111
+
112
+ if (!Icon) return null;
113
+
114
+ return (
115
+ <Icon
116
+ size={size}
117
+ color={color}
118
+ strokeWidth={strokeWidth}
119
+ className={cn(className)}
120
+ {...props}
121
+ />
122
+ );
123
+ };
124
+
58
125
  export default DynamicIcon;
Binary file
Binary file
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stevederico/skateboard-ui",
3
3
  "private": false,
4
- "version": "2.10.0",
4
+ "version": "2.11.0",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  "./Sidebar": {
package/styles.css CHANGED
@@ -1,3 +1,19 @@
1
+ @font-face {
2
+ font-family: 'Geist';
3
+ src: url('./fonts/GeistVF.woff2') format('woff2');
4
+ font-weight: 100 900;
5
+ font-style: normal;
6
+ font-display: swap;
7
+ }
8
+
9
+ @font-face {
10
+ font-family: 'Geist Mono';
11
+ src: url('./fonts/GeistMonoVF.woff2') format('woff2');
12
+ font-weight: 100 900;
13
+ font-style: normal;
14
+ font-display: swap;
15
+ }
16
+
1
17
  @import "tailwindcss";
2
18
 
3
19
  @source '../';
@@ -2,23 +2,42 @@ import React from 'react';
2
2
  import { useNavigate } from 'react-router-dom';
3
3
  import { useTheme } from 'next-themes';
4
4
  import { getState } from "../core/Context.jsx";
5
- import DynamicIcon, { canResolveIcon } from '../core/DynamicIcon.jsx';
5
+ import DynamicIcon from '../core/DynamicIcon.jsx';
6
6
  import { Sun, Moon, Check } from 'lucide-react';
7
7
  import { Button } from '../shadcn/ui/button.jsx';
8
8
  import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '../shadcn/ui/card.jsx';
9
9
  import { Badge } from '../shadcn/ui/badge.jsx';
10
10
  import { Separator } from '../shadcn/ui/separator.jsx';
11
11
 
12
+ const CURRENT_YEAR = new Date().getFullYear();
13
+
14
+ const DEFAULT_NAV_LINKS = [
15
+ { label: 'Features', href: '#features' },
16
+ ];
17
+
18
+ const DEFAULT_NAV_LINKS_SUFFIX = [
19
+ { label: 'Terms', href: '/terms' },
20
+ ];
21
+
22
+ const PRICING_LINK = { label: 'Pricing', href: '#pricing' };
23
+
24
+ const PRIVACY_LINK = { label: 'Privacy', href: '/privacy' };
25
+ const TERMS_LINK = { label: 'Terms', href: '/terms' };
26
+ const EULA_LINK = { label: 'EULA', href: '/eula' };
27
+
12
28
  /**
13
29
  * Renders a feature icon from a name string or emoji.
14
- * Resolves Lucide icon names (kebab-case), falls back to raw text (emoji).
30
+ * If the name looks like an icon identifier (ASCII letters, digits, hyphens),
31
+ * renders it via DynamicIcon with lazy loading. Otherwise treats it as
32
+ * raw text (e.g. an emoji).
15
33
  *
16
34
  * @param {Object} props
17
35
  * @param {string} props.name - Icon name (kebab-case) or emoji string
18
36
  * @returns {JSX.Element} Icon component or text span
19
37
  */
20
38
  function FeatureIcon({ name }) {
21
- if (canResolveIcon(name)) return <DynamicIcon name={name} size={24} />;
39
+ const isIconName = /^[a-z][a-z0-9-]*$/i.test(name);
40
+ if (isIconName) return <DynamicIcon name={name} size={24} />;
22
41
  return <span>{name}</span>;
23
42
  }
24
43
 
@@ -57,9 +76,9 @@ export default function LandingView() {
57
76
 
58
77
  <div className="hidden md:flex gap-6">
59
78
  {(constants.navLinks || [
60
- { label: 'Features', href: '#features' },
61
- ...(constants.stripeProducts?.length > 0 ? [{ label: 'Pricing', href: '#pricing' }] : []),
62
- { label: 'Terms', href: '/terms' },
79
+ ...DEFAULT_NAV_LINKS,
80
+ ...(constants.stripeProducts?.length > 0 ? [PRICING_LINK] : []),
81
+ ...DEFAULT_NAV_LINKS_SUFFIX,
63
82
  ]).map((link, index) => (
64
83
  <a key={index} href={link.href} className="text-muted-foreground hover:text-foreground transition-colors font-semibold">{link.label}</a>
65
84
  ))}
@@ -171,14 +190,14 @@ export default function LandingView() {
171
190
  <Separator className="mb-8" />
172
191
  <div className="flex justify-center gap-8 mb-6">
173
192
  {(constants.footerLinks || [
174
- ...(constants.privacyPolicy ? [{ label: 'Privacy', href: '/privacy' }] : []),
175
- ...(constants.termsOfService ? [{ label: 'Terms', href: '/terms' }] : []),
176
- ...(constants.EULA ? [{ label: 'EULA', href: '/eula' }] : []),
193
+ ...(constants.privacyPolicy ? [PRIVACY_LINK] : []),
194
+ ...(constants.termsOfService ? [TERMS_LINK] : []),
195
+ ...(constants.EULA ? [EULA_LINK] : []),
177
196
  ]).map((link, index) => (
178
197
  <a key={index} href={link.href} className="text-muted-foreground hover:text-foreground transition-colors font-semibold">{link.label}</a>
179
198
  ))}
180
199
  </div>
181
- <p className="text-center text-muted-foreground">&copy; {new Date().getFullYear()} {constants.companyName}. {constants.copyrightText || 'All rights reserved.'}</p>
200
+ <p className="text-center text-muted-foreground">&copy; {CURRENT_YEAR} {constants.companyName}. {constants.copyrightText || 'All rights reserved.'}</p>
182
201
  </div>
183
202
  </footer>
184
203
  </div>