@stateglyph/cli 0.1.0 → 0.2.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/README.md +5 -0
- package/dist/index.js +38 -67
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -21,4 +21,9 @@ The generated components are TypeScript React files. They are self-contained
|
|
|
21
21
|
and depend only on `react` and `lucide-react`, so you can edit them freely in
|
|
22
22
|
your own project.
|
|
23
23
|
|
|
24
|
+
Each copied component includes animated outgoing/incoming glyph transitions,
|
|
25
|
+
continuous rotation where defined, and live reduced-motion support. Customize
|
|
26
|
+
`animated`, `duration`, `spinDuration`, and `transition` without installing a
|
|
27
|
+
StateGlyph runtime package.
|
|
28
|
+
|
|
24
29
|
MIT licensed. The source icons from Lucide use the ISC License.
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
+
import { readFileSync } from "fs";
|
|
4
5
|
import { Command } from "commander";
|
|
5
6
|
|
|
6
7
|
// src/add.ts
|
|
@@ -8,6 +9,9 @@ import { access, mkdir, writeFile } from "fs/promises";
|
|
|
8
9
|
import path from "path";
|
|
9
10
|
import { stateIconCatalog } from "@stateglyph/core";
|
|
10
11
|
|
|
12
|
+
// src/animation-source.ts
|
|
13
|
+
var animationSource = '"use client";\n\nimport {\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type SVGProps,\n} from "react";\nimport type { LucideIcon } from "lucide-react";\n\ntype TransitionName = "crossfade" | "scale-fade" | "rotate" | "slide" | "morph";\n\ntype AnimatedIconProps = Omit<\n SVGProps<SVGSVGElement>,\n "children" | "dangerouslySetInnerHTML"\n> & {\n icon: LucideIcon;\n iconName: string;\n iconId: string;\n state: string;\n transition?: TransitionName;\n duration?: number;\n spinDuration?: number;\n animated?: boolean;\n continuous?: boolean;\n decorative?: boolean;\n label?: string;\n size?: number | string;\n strokeWidth?: number;\n};\n\ntype Snapshot = Pick<\n AnimatedIconProps,\n "icon" | "iconName" | "iconId" | "state" | "continuous"\n>;\ntype Frame = { current: Snapshot; previous: Snapshot | null; revision: number };\n\nconst defaultDurations = {\n crossfade: 180,\n "scale-fade": 200,\n rotate: 240,\n slide: 220,\n morph: 240,\n};\n\nfunction motionPreference() {\n return (\n typeof window !== "undefined" &&\n !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches\n );\n}\n\nfunction subscribeToMotion(callback: () => void) {\n if (typeof window === "undefined" || !window.matchMedia) return () => {};\n const query = window.matchMedia("(prefers-reduced-motion: reduce)");\n query.addEventListener("change", callback);\n return () => query.removeEventListener("change", callback);\n}\n\nfunction milliseconds(value: string, fallback: number) {\n const parsed = Number.parseFloat(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(\n 0,\n value.trim().endsWith("ms")\n ? parsed\n : value.trim().endsWith("s")\n ? parsed * 1000\n : parsed,\n );\n}\n\nfunction transitionFrames(transition: TransitionName): {\n incoming: Keyframe[];\n outgoing: Keyframe[];\n} {\n const rest = {\n opacity: 1,\n transform: "translateY(0) scale(1) rotate(0deg)",\n filter: "blur(0px)",\n };\n const poses = {\n crossfade: [{ opacity: 0 }, { opacity: 0 }],\n "scale-fade": [\n { opacity: 0, transform: "scale(0.65)" },\n { opacity: 0, transform: "scale(1.2)" },\n ],\n rotate: [\n { opacity: 0, transform: "rotate(-60deg) scale(0.8)" },\n { opacity: 0, transform: "rotate(60deg) scale(0.8)" },\n ],\n slide: [\n { opacity: 0, transform: "translateY(6px)" },\n { opacity: 0, transform: "translateY(-6px)" },\n ],\n morph: [\n {\n opacity: 0,\n transform: "scale(0.75) rotate(-18deg)",\n filter: "blur(2px)",\n },\n {\n opacity: 0,\n transform: "scale(0.75) rotate(18deg)",\n filter: "blur(2px)",\n },\n ],\n } satisfies Record<TransitionName, [Keyframe, Keyframe]>;\n const [start, end] = poses[transition];\n return { incoming: [start, rest], outgoing: [rest, end] };\n}\n\nfunction AnimatedIcon({\n icon,\n iconName,\n iconId,\n state,\n continuous = false,\n transition = "crossfade",\n duration,\n spinDuration,\n animated = true,\n decorative = true,\n label,\n size = 24,\n strokeWidth = 2,\n className,\n style,\n ...svgProps\n}: AnimatedIconProps) {\n const reducedMotion = useSyncExternalStore(\n subscribeToMotion,\n motionPreference,\n () => false,\n );\n const rootRef = useRef<SVGSVGElement>(null);\n const incomingRef = useRef<SVGGElement>(null);\n const outgoingRef = useRef<SVGGElement>(null);\n const spinRef = useRef<SVGGElement>(null);\n const [frame, setFrame] = useState<Frame>(() => ({\n current: { icon, iconName, iconId, state, continuous },\n previous: null,\n revision: 0,\n }));\n\n // Keep the outgoing glyph until its exit completes. Updating a guarded snapshot\n // during render avoids an intermediate paint with the new state and old glyph.\n if (\n frame.current.state !== state ||\n frame.current.iconId !== iconId ||\n frame.current.icon !== icon ||\n frame.current.continuous !== continuous\n ) {\n setFrame({\n current: { icon, iconName, iconId, state, continuous },\n previous: frame.current.iconId === iconId ? frame.current : null,\n revision: frame.revision + 1,\n });\n }\n\n useEffect(() => {\n let active = true;\n const animations: Animation[] = [];\n const root = rootRef.current;\n const incoming = incomingRef.current;\n const outgoing = outgoingRef.current;\n const finish = () => {\n if (!active) return;\n setFrame((current) =>\n current.revision === frame.revision && current.previous\n ? { ...current, previous: null }\n : current,\n );\n };\n const computed = root ? getComputedStyle(root) : null;\n const timing = Number.isFinite(duration)\n ? Math.max(0, duration!)\n : milliseconds(\n (computed?.getPropertyValue("--stateglyph-duration") ||\n computed?.getPropertyValue("--stateglyph-preset-duration")) ??\n "",\n defaultDurations[transition],\n );\n const easing =\n computed?.getPropertyValue("--stateglyph-easing").trim() ||\n computed?.getPropertyValue("--stateglyph-preset-easing").trim() ||\n "cubic-bezier(0.22, 1, 0.36, 1)";\n const canAnimate =\n animated && !reducedMotion && typeof incoming?.animate === "function";\n\n if (frame.previous && outgoing && incoming && canAnimate && timing > 0) {\n const frames = transitionFrames(transition);\n try {\n const exit = outgoing.animate(frames.outgoing, {\n duration: timing,\n easing,\n fill: "both",\n });\n animations.push(exit);\n const enter = incoming.animate(frames.incoming, {\n duration: timing,\n easing,\n fill: "both",\n });\n animations.push(enter);\n enter.onfinish = finish;\n } catch {\n // Invalid consumer-provided CSS timing must never hide an icon.\n for (const animation of animations) animation.cancel();\n queueMicrotask(finish);\n }\n } else if (frame.previous) {\n queueMicrotask(finish);\n }\n\n return () => {\n active = false;\n for (const animation of animations) {\n animation.onfinish = null;\n animation.cancel();\n }\n };\n }, [frame, animated, reducedMotion, duration, transition]);\n\n // Continuous motion has a separate lifecycle so finishing an entrance does\n // not reset the spinner\'s rotation phase.\n useEffect(() => {\n const spin = spinRef.current;\n if (\n !animated ||\n reducedMotion ||\n !frame.current.continuous ||\n !spin ||\n typeof spin.animate !== "function"\n )\n return;\n const computed = rootRef.current ? getComputedStyle(rootRef.current) : null;\n const timing = Number.isFinite(spinDuration)\n ? Math.max(0, spinDuration!)\n : milliseconds(\n (computed?.getPropertyValue("--stateglyph-spin-duration") ||\n computed?.getPropertyValue("--stateglyph-preset-spin-duration")) ??\n "",\n 900,\n );\n if (timing <= 0) return;\n const animation = spin.animate(\n [{ transform: "rotate(0deg)" }, { transform: "rotate(360deg)" }],\n { duration: timing, iterations: Infinity, easing: "linear" },\n );\n return () => animation.cancel();\n }, [\n frame.revision,\n frame.current.continuous,\n animated,\n reducedMotion,\n spinDuration,\n ]);\n\n const CurrentIcon = frame.current.icon;\n const PreviousIcon = frame.previous?.icon;\n const layerStyle = {\n transformBox: "view-box",\n transformOrigin: "12px 12px",\n } as const;\n\n return (\n <svg\n xmlns="http://www.w3.org/2000/svg"\n width={size}\n height={size}\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n strokeWidth={strokeWidth}\n strokeLinecap="round"\n strokeLinejoin="round"\n {...svgProps}\n ref={rootRef}\n className={`lucide lucide-${iconName}${className ? ` ${className}` : ""}`}\n style={{\n display: "inline-block",\n verticalAlign: "middle",\n flexShrink: 0,\n overflow: "visible",\n ...style,\n }}\n data-state-icon={iconId}\n data-state={state}\n data-transition={transition}\n data-state-continuous={continuous ? "true" : undefined}\n aria-hidden={decorative ? true : undefined}\n aria-label={decorative ? undefined : label}\n role={decorative ? undefined : "img"}\n focusable="false"\n >\n {PreviousIcon && animated && !reducedMotion && duration !== 0 ? (\n <g\n key={`outgoing-${frame.revision}`}\n ref={outgoingRef}\n data-state-layer="outgoing"\n aria-hidden="true"\n style={layerStyle}\n >\n <PreviousIcon\n size={24}\n strokeWidth={strokeWidth}\n stroke="inherit"\n fill="inherit"\n aria-hidden="true"\n focusable="false"\n />\n </g>\n ) : null}\n <g\n key={`incoming-${frame.revision}`}\n ref={incomingRef}\n data-state-layer="incoming"\n style={{\n ...layerStyle,\n opacity:\n PreviousIcon && animated && !reducedMotion && duration !== 0\n ? 0\n : 1,\n }}\n >\n <g ref={spinRef} data-state-layer="glyph" style={layerStyle}>\n <CurrentIcon\n size={24}\n strokeWidth={strokeWidth}\n stroke="inherit"\n fill="inherit"\n aria-hidden="true"\n focusable="false"\n />\n </g>\n </g>\n </svg>\n );\n}\n';
|
|
14
|
+
|
|
11
15
|
// src/template.ts
|
|
12
16
|
function toPascalCase(value) {
|
|
13
17
|
return value.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
|
|
@@ -17,85 +21,42 @@ function renderIconComponent(definition) {
|
|
|
17
21
|
const stateTypeName = `${toPascalCase(definition.id)}State`;
|
|
18
22
|
const propsTypeName = `${componentName}Props`;
|
|
19
23
|
const states = Object.entries(definition.states);
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
const glyphs = [...new Set(states.map(([, value]) => value.icon))];
|
|
25
|
+
const imports = glyphs.map(toPascalCase).sort();
|
|
26
|
+
const runtime = animationSource.replace(
|
|
27
|
+
'import type { LucideIcon } from "lucide-react";',
|
|
28
|
+
`import { ${imports.join(", ")}, type LucideIcon } from "lucide-react";`
|
|
29
|
+
);
|
|
25
30
|
const stateUnion = states.map(([name]) => JSON.stringify(name)).join(" | ");
|
|
26
31
|
const iconRows = states.map(
|
|
27
|
-
([name,
|
|
32
|
+
([name, value]) => ` ${JSON.stringify(name)}: ${toPascalCase(value.icon)},`
|
|
33
|
+
).join("\n");
|
|
34
|
+
const nameRows = states.map(
|
|
35
|
+
([name, value]) => ` ${JSON.stringify(name)}: ${JSON.stringify(value.icon)},`
|
|
28
36
|
).join("\n");
|
|
29
37
|
const labelRows = states.map(
|
|
30
|
-
([name,
|
|
38
|
+
([name, value]) => ` ${JSON.stringify(name)}: ${JSON.stringify(value.label)},`
|
|
31
39
|
).join("\n");
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
${lucideComponents.map((name) => ` ${name},`).join("\n")}
|
|
35
|
-
type LucideIcon,
|
|
36
|
-
} from "lucide-react";
|
|
37
|
-
|
|
40
|
+
const continuous = states.filter(([, value]) => value.continuous).map(([name]) => name);
|
|
41
|
+
return `${runtime}
|
|
38
42
|
export type ${stateTypeName} = ${stateUnion};
|
|
39
|
-
|
|
40
|
-
export type ${propsTypeName} = Omit<SVGProps<SVGSVGElement>, "children"> & {
|
|
41
|
-
state: ${stateTypeName};
|
|
42
|
-
size?: number | string;
|
|
43
|
-
strokeWidth?: number;
|
|
44
|
-
duration?: number;
|
|
45
|
-
decorative?: boolean;
|
|
46
|
-
label?: string;
|
|
47
|
-
};
|
|
48
|
-
|
|
43
|
+
export type ${propsTypeName} = Omit<AnimatedIconProps, "icon" | "iconName" | "iconId" | "state" | "continuous"> & { state: ${stateTypeName}; };
|
|
49
44
|
const iconByState = {
|
|
50
45
|
${iconRows}
|
|
51
46
|
} satisfies Record<${stateTypeName}, LucideIcon>;
|
|
52
|
-
|
|
47
|
+
const nameByState = {
|
|
48
|
+
${nameRows}
|
|
49
|
+
} satisfies Record<${stateTypeName}, string>;
|
|
53
50
|
const labelByState = {
|
|
54
51
|
${labelRows}
|
|
55
52
|
} satisfies Record<${stateTypeName}, string>;
|
|
53
|
+
const continuousStates = new Set<${stateTypeName}>(${JSON.stringify(continuous)});
|
|
56
54
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
export function ${componentName}({
|
|
60
|
-
state,
|
|
61
|
-
size = 24,
|
|
62
|
-
strokeWidth = 2,
|
|
63
|
-
duration = 900,
|
|
64
|
-
decorative = true,
|
|
65
|
-
label,
|
|
66
|
-
style,
|
|
67
|
-
...svgProps
|
|
68
|
-
}: ${propsTypeName}) {
|
|
55
|
+
export function ${componentName}({ state, label, transition = ${JSON.stringify(definition.transition ?? "crossfade")}, ...props }: ${propsTypeName}) {
|
|
69
56
|
const Icon = iconByState[state];
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return (
|
|
74
|
-
<>
|
|
75
|
-
{isContinuous ? (
|
|
76
|
-
<style>{\`@keyframes ${animationName} { to { transform: rotate(360deg); } } @media (prefers-reduced-motion: reduce) { [data-state-icon="${definition.id}"] { animation: none !important; } }\`}</style>
|
|
77
|
-
) : null}
|
|
78
|
-
<Icon
|
|
79
|
-
{...svgProps}
|
|
80
|
-
size={size}
|
|
81
|
-
strokeWidth={strokeWidth}
|
|
82
|
-
data-state-icon="${definition.id}"
|
|
83
|
-
data-state={state}
|
|
84
|
-
aria-hidden={decorative ? true : undefined}
|
|
85
|
-
aria-label={decorative ? undefined : accessibleLabel}
|
|
86
|
-
role={decorative ? undefined : "img"}
|
|
87
|
-
focusable="false"
|
|
88
|
-
style={{
|
|
89
|
-
...style,
|
|
90
|
-
animation:
|
|
91
|
-
style?.animation ??
|
|
92
|
-
(isContinuous
|
|
93
|
-
? \`${animationName} \${duration}ms linear infinite\`
|
|
94
|
-
: undefined),
|
|
95
|
-
}}
|
|
96
|
-
/>
|
|
97
|
-
</>
|
|
98
|
-
);
|
|
57
|
+
if (!Icon) return null;
|
|
58
|
+
return <AnimatedIcon {...props} icon={Icon} iconName={nameByState[state]} iconId=${JSON.stringify(definition.id)}
|
|
59
|
+
state={state} label={label ?? labelByState[state]} transition={transition} continuous={continuousStates.has(state)} />;
|
|
99
60
|
}
|
|
100
61
|
`;
|
|
101
62
|
}
|
|
@@ -153,7 +114,14 @@ var categoryLabels = {
|
|
|
153
114
|
device: "Device and connection",
|
|
154
115
|
form: "Form states",
|
|
155
116
|
commerce: "Commerce",
|
|
156
|
-
notification: "Notifications"
|
|
117
|
+
notification: "Notifications",
|
|
118
|
+
connectivity: "Connectivity",
|
|
119
|
+
security: "Security and privacy",
|
|
120
|
+
appearance: "Appearance",
|
|
121
|
+
editing: "Editing tools",
|
|
122
|
+
files: "Files and folders",
|
|
123
|
+
weather: "Weather and time",
|
|
124
|
+
productivity: "Productivity"
|
|
157
125
|
};
|
|
158
126
|
function formatIconList() {
|
|
159
127
|
const sections = Object.entries(categoryLabels).flatMap(
|
|
@@ -180,8 +148,11 @@ function formatIconList() {
|
|
|
180
148
|
}
|
|
181
149
|
|
|
182
150
|
// src/index.ts
|
|
151
|
+
var { version } = JSON.parse(
|
|
152
|
+
readFileSync(new URL("../package.json", import.meta.url), "utf8")
|
|
153
|
+
);
|
|
183
154
|
var program = new Command();
|
|
184
|
-
program.name("stateglyph").description("Browse and add StateGlyph components to a React project.").version(
|
|
155
|
+
program.name("stateglyph").description("Browse and add StateGlyph components to a React project.").version(version).showHelpAfterError();
|
|
185
156
|
program.command("list").description("List every available icon, grouped by category.").action(() => {
|
|
186
157
|
process.stdout.write(`${formatIconList()}
|
|
187
158
|
`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stateglyph/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Copy StateGlyph components into React projects.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Kamalveer Singh",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"prepublishOnly": "npm run build"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@stateglyph/core": "0.
|
|
44
|
+
"@stateglyph/core": "0.2.0",
|
|
45
45
|
"commander": "^15.0.0"
|
|
46
46
|
},
|
|
47
47
|
"engines": {
|