@stevederico/skateboard-ui 2.10.1 → 2.12.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 +15 -0
- package/core/DynamicIcon.jsx +98 -31
- package/layout/Layout.jsx +4 -1
- package/package.json +1 -1
- package/shadcn/ui/button.jsx +1 -1
- package/styles.css +10 -0
- package/views/LandingView.jsx +29 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
2.12.0
|
|
4
|
+
|
|
5
|
+
Add skip-to-content link
|
|
6
|
+
Add selection styling brand color
|
|
7
|
+
Add content-auto utility class
|
|
8
|
+
Add button active press feedback
|
|
9
|
+
Add main landmark id
|
|
10
|
+
|
|
11
|
+
2.11.0
|
|
12
|
+
|
|
13
|
+
Fix DynamicIcon lazy loading
|
|
14
|
+
Remove barrel import lucide-react
|
|
15
|
+
Hoist LandingView static values
|
|
16
|
+
Add FeatureIcon emoji fallback
|
|
17
|
+
|
|
3
18
|
2.10.1
|
|
4
19
|
|
|
5
20
|
Add Geist font files self-hosted
|
package/core/DynamicIcon.jsx
CHANGED
|
@@ -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
|
-
*
|
|
5
|
+
* Convert a kebab-case, snake_case, or space-separated string to PascalCase.
|
|
7
6
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
|
|
35
|
-
|
|
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 (
|
|
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
|
-
|
|
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;
|
package/layout/Layout.jsx
CHANGED
|
@@ -40,6 +40,9 @@ export default function Layout({ children }) {
|
|
|
40
40
|
|
|
41
41
|
return (
|
|
42
42
|
<div className="min-h-screen flex flex-col pt-[env(safe-area-inset-top)] pb-[calc(5rem+env(safe-area-inset-bottom))] md:pb-[env(safe-area-inset-bottom)] pl-[env(safe-area-inset-left)] pr-[env(safe-area-inset-right)]">
|
|
43
|
+
<a href="#main" className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-background focus:text-foreground focus:rounded-md focus:ring-2 focus:ring-ring">
|
|
44
|
+
Skip to content
|
|
45
|
+
</a>
|
|
43
46
|
<SidebarProvider
|
|
44
47
|
defaultOpen={!constants.sidebarCollapsed}
|
|
45
48
|
style={{
|
|
@@ -47,7 +50,7 @@ export default function Layout({ children }) {
|
|
|
47
50
|
'--header-height': '3.5rem',
|
|
48
51
|
}}>
|
|
49
52
|
{showSidebar && <Sidebar variant="inset" />}
|
|
50
|
-
<SidebarInset className={`border border-border/50 ${constants.hideSidebarInsetRounding ? "md:peer-data-[variant=inset]:rounded-none" : ""}`}>
|
|
53
|
+
<SidebarInset id="main" className={`border border-border/50 ${constants.hideSidebarInsetRounding ? "md:peer-data-[variant=inset]:rounded-none" : ""}`}>
|
|
51
54
|
<Outlet />
|
|
52
55
|
</SidebarInset>
|
|
53
56
|
</SidebarProvider>
|
package/package.json
CHANGED
package/shadcn/ui/button.jsx
CHANGED
|
@@ -4,7 +4,7 @@ import { cva } from "class-variance-authority";
|
|
|
4
4
|
import { cn } from "../lib/utils.js"
|
|
5
5
|
|
|
6
6
|
const buttonVariants = cva(
|
|
7
|
-
"cursor-pointer focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
|
|
7
|
+
"cursor-pointer focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none active:scale-[0.98]",
|
|
8
8
|
{
|
|
9
9
|
variants: {
|
|
10
10
|
variant: {
|
package/styles.css
CHANGED
|
@@ -209,6 +209,16 @@
|
|
|
209
209
|
code, pre, kbd {
|
|
210
210
|
font-family: 'Geist Mono', ui-monospace, monospace;
|
|
211
211
|
}
|
|
212
|
+
::selection {
|
|
213
|
+
background-color: color-mix(in oklch, var(--color-app) 20%, transparent);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
@layer utilities {
|
|
218
|
+
.content-auto {
|
|
219
|
+
content-visibility: auto;
|
|
220
|
+
contain-intrinsic-size: auto 200px;
|
|
221
|
+
}
|
|
212
222
|
}
|
|
213
223
|
|
|
214
224
|
#root {
|
package/views/LandingView.jsx
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
...(constants.stripeProducts?.length > 0 ? [
|
|
62
|
-
|
|
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 ? [
|
|
175
|
-
...(constants.termsOfService ? [
|
|
176
|
-
...(constants.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">© {
|
|
200
|
+
<p className="text-center text-muted-foreground">© {CURRENT_YEAR} {constants.companyName}. {constants.copyrightText || 'All rights reserved.'}</p>
|
|
182
201
|
</div>
|
|
183
202
|
</footer>
|
|
184
203
|
</div>
|