pinfold 0.0.1 → 0.1.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/bin/pinfold.mjs +236 -0
- package/lib/commands.mjs +339 -0
- package/lib/detect.mjs +92 -0
- package/lib/mcp.mjs +124 -0
- package/lib/plan.mjs +170 -0
- package/package.json +38 -6
- package/registry.json +21 -0
- package/templates/component/css/__NAME__.css.tmpl +101 -0
- package/templates/component/react/__NAME__.test.tsx.tmpl +66 -0
- package/templates/component/react/__PASCAL__.tsx.tmpl +13 -0
- package/templates/component/react/__PASCAL__View.tsx.tmpl +114 -0
- package/templates/component/react/index.ts.tmpl +22 -0
- package/templates/component/react/lazy.ts.tmpl +11 -0
- package/templates/component/react/types.ts.tmpl +142 -0
- package/templates/component/react/use__PASCAL__.tsx.tmpl +181 -0
- package/LICENSE +0 -21
- package/README.md +0 -6
- package/bin/pinfold.js +0 -2
package/lib/mcp.mjs
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `npx pinfold mcp`: a local Model Context Protocol server over stdio
|
|
3
|
+
* (newline-delimited JSON-RPC 2.0). It answers from the installed
|
|
4
|
+
* @pinfold/core meta, so the docs always match the installed version and
|
|
5
|
+
* nothing is hosted.
|
|
6
|
+
*
|
|
7
|
+
* { "mcpServers": { "pinfold": { "command": "npx", "args": ["pinfold", "mcp"] } } }
|
|
8
|
+
*/
|
|
9
|
+
import { createInterface } from 'node:readline';
|
|
10
|
+
import { componentMetas, metaToMarkdown, toJsonSchema, validateConfig } from '@pinfold/core/meta';
|
|
11
|
+
|
|
12
|
+
const VERSION = '0.1.0';
|
|
13
|
+
const byName = (name) => componentMetas.find((m) => m.name === name);
|
|
14
|
+
const names = () => componentMetas.map((m) => m.name);
|
|
15
|
+
const nameArg = { type: 'object', properties: { name: { type: 'string', enum: names() } }, required: ['name'] };
|
|
16
|
+
|
|
17
|
+
const TOOLS = [
|
|
18
|
+
{
|
|
19
|
+
name: 'list_components',
|
|
20
|
+
description: 'List every Pinfold component with its import path and one-line description.',
|
|
21
|
+
inputSchema: { type: 'object', properties: {} },
|
|
22
|
+
run: () => componentMetas.map((m) => `- ${m.name}: ${m.description} Import \`${m.importPath}\`.`).join('\n'),
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: 'get_component',
|
|
26
|
+
description: 'Full reference for one component: usage (client hook and Server Component), every option with defaults, rows, slots, keyboard, ARIA.',
|
|
27
|
+
inputSchema: nameArg,
|
|
28
|
+
run: ({ name }) => metaToMarkdown(required(name)),
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: 'get_schema',
|
|
32
|
+
description: "JSON Schema of a component's config. Use it to generate or check config objects.",
|
|
33
|
+
inputSchema: nameArg,
|
|
34
|
+
run: ({ name }) => JSON.stringify(toJsonSchema(required(name)), null, 2),
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: 'validate_config',
|
|
38
|
+
description: 'Check a config object for a component: missing required keys, unknown keys (with did-you-mean), wrong types, bad enum values.',
|
|
39
|
+
inputSchema: {
|
|
40
|
+
type: 'object',
|
|
41
|
+
properties: { name: { type: 'string', enum: names() }, config: { type: 'object' } },
|
|
42
|
+
required: ['name', 'config'],
|
|
43
|
+
},
|
|
44
|
+
run: ({ name, config }) => {
|
|
45
|
+
const problems = validateConfig(required(name), config);
|
|
46
|
+
return problems.length ? problems.map((p) => `- ${p.message}`).join('\n') : 'Valid: no problems found.';
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'get_install_steps',
|
|
51
|
+
description: 'How to add Pinfold (optionally one component) to a project.',
|
|
52
|
+
inputSchema: { type: 'object', properties: { name: { type: 'string' } } },
|
|
53
|
+
run: ({ name } = {}) =>
|
|
54
|
+
[
|
|
55
|
+
'1. `npx pinfold init` (detects Next.js/Vite/React Router, TS or JS, Tailwind; writes config, theme, CSS imports). Non-interactive: `--yes`.',
|
|
56
|
+
`2. \`npx pinfold add ${name ?? '<component>'}\` writes components/ui/${name ?? '<component>'}.tsx (or .jsx) and its CSS import.`,
|
|
57
|
+
'3. Client Component: `const X = use<Name>({ items }); return <X />`. Server Component: `define<Name>({ items })`.',
|
|
58
|
+
'4. `npx pinfold doctor` checks the setup.',
|
|
59
|
+
].join('\n'),
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
function required(name) {
|
|
64
|
+
const meta = byName(name);
|
|
65
|
+
if (!meta) throw new Error(`Unknown component "${name}". Known: ${names().join(', ')}.`);
|
|
66
|
+
return meta;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** One JSON-RPC request in, one response out (null for notifications). */
|
|
70
|
+
export function handle(message) {
|
|
71
|
+
const { id, method, params = {} } = message;
|
|
72
|
+
const reply = (result) => (id === undefined ? null : { jsonrpc: '2.0', id, result });
|
|
73
|
+
const fail = (code, msg) => (id === undefined ? null : { jsonrpc: '2.0', id, error: { code, message: msg } });
|
|
74
|
+
|
|
75
|
+
switch (method) {
|
|
76
|
+
case 'initialize':
|
|
77
|
+
return reply({
|
|
78
|
+
protocolVersion: params.protocolVersion ?? '2025-06-18',
|
|
79
|
+
capabilities: { tools: {}, resources: {} },
|
|
80
|
+
serverInfo: { name: 'pinfold', version: VERSION },
|
|
81
|
+
instructions: 'Pinfold UI reference. Call list_components, then get_component before writing Pinfold code; validate_config to check a config.',
|
|
82
|
+
});
|
|
83
|
+
case 'ping':
|
|
84
|
+
return reply({});
|
|
85
|
+
case 'tools/list':
|
|
86
|
+
return reply({ tools: TOOLS.map(({ run, ...tool }) => tool) });
|
|
87
|
+
case 'tools/call': {
|
|
88
|
+
const tool = TOOLS.find((t) => t.name === params.name);
|
|
89
|
+
if (!tool) return fail(-32602, `Unknown tool ${params.name}`);
|
|
90
|
+
try {
|
|
91
|
+
return reply({ content: [{ type: 'text', text: tool.run(params.arguments ?? {}) }] });
|
|
92
|
+
} catch (error) {
|
|
93
|
+
return reply({ content: [{ type: 'text', text: error.message }], isError: true });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
case 'resources/list':
|
|
97
|
+
return reply({
|
|
98
|
+
resources: componentMetas.map((m) => ({ uri: `pinfold://components/${m.name}`, name: m.displayName, mimeType: 'text/markdown', description: m.description })),
|
|
99
|
+
});
|
|
100
|
+
case 'resources/read': {
|
|
101
|
+
const meta = byName(String(params.uri ?? '').replace('pinfold://components/', ''));
|
|
102
|
+
if (!meta) return fail(-32602, `Unknown resource ${params.uri}`);
|
|
103
|
+
return reply({ contents: [{ uri: params.uri, mimeType: 'text/markdown', text: metaToMarkdown(meta) }] });
|
|
104
|
+
}
|
|
105
|
+
default:
|
|
106
|
+
if (method?.startsWith('notifications/')) return null;
|
|
107
|
+
return fail(-32601, `Method not found: ${method}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function serve(input = process.stdin, output = process.stdout) {
|
|
112
|
+
const rl = createInterface({ input });
|
|
113
|
+
rl.on('line', (line) => {
|
|
114
|
+
if (!line.trim()) return;
|
|
115
|
+
let response;
|
|
116
|
+
try {
|
|
117
|
+
response = handle(JSON.parse(line));
|
|
118
|
+
} catch {
|
|
119
|
+
response = { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } };
|
|
120
|
+
}
|
|
121
|
+
if (response) output.write(JSON.stringify(response) + '\n');
|
|
122
|
+
});
|
|
123
|
+
return new Promise((done) => rl.on('close', done));
|
|
124
|
+
}
|
package/lib/plan.mjs
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every command describes its changes as a plan first. The plan is what the
|
|
3
|
+
* safety rules hang on: nothing is written until it is printed (and, when
|
|
4
|
+
* interactive, confirmed); `--dry-run` stops after printing; existing files are
|
|
5
|
+
* only ever merged into, never replaced; and a plan whose files already say the
|
|
6
|
+
* right thing is empty, which is what makes every command idempotent.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
export class Plan {
|
|
12
|
+
constructor(cwd) {
|
|
13
|
+
this.cwd = cwd;
|
|
14
|
+
/** path -> { before: string | null, after: string } */
|
|
15
|
+
this.changes = new Map();
|
|
16
|
+
this.notes = [];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Current content: planned, else on disk, else null. */
|
|
20
|
+
read(path) {
|
|
21
|
+
if (this.changes.has(path)) return this.changes.get(path).after;
|
|
22
|
+
const file = join(this.cwd, path);
|
|
23
|
+
return existsSync(file) ? readFileSync(file, 'utf8') : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Merge into a file (or create it). Records nothing when the result is unchanged. */
|
|
27
|
+
update(path, transform) {
|
|
28
|
+
const current = this.read(path);
|
|
29
|
+
const next = transform(current ?? '');
|
|
30
|
+
if (next === current) return false;
|
|
31
|
+
const before = this.changes.has(path) ? this.changes.get(path).before : current;
|
|
32
|
+
if (next === before) this.changes.delete(path);
|
|
33
|
+
else this.changes.set(path, { before, after: next });
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Create a file the developer will own. An existing file is never overwritten. */
|
|
38
|
+
create(path, content) {
|
|
39
|
+
const current = this.read(path);
|
|
40
|
+
if (current === null) return this.update(path, () => content);
|
|
41
|
+
if (current !== content) this.notes.push(`kept ${path} (exists; yours to edit)`);
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Write a file Pinfold owns and regenerates (the theme CSS). */
|
|
46
|
+
generate(path, content) {
|
|
47
|
+
return this.update(path, () => content);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
note(message) {
|
|
51
|
+
this.notes.push(message);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
get empty() {
|
|
55
|
+
return this.changes.size === 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A compact diff: new files by line count, edited files by their added and removed lines. */
|
|
59
|
+
describe(color) {
|
|
60
|
+
const out = [];
|
|
61
|
+
for (const [path, { before, after }] of this.changes) {
|
|
62
|
+
if (before === null) {
|
|
63
|
+
out.push(` ${color.ok('create')} ${path} ${color.dim(`(${after.split('\n').length} lines)`)}`);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
out.push(` ${color.warn('update')} ${path}`);
|
|
67
|
+
const was = before.split('\n');
|
|
68
|
+
const now = after.split('\n');
|
|
69
|
+
for (const line of now) if (!was.includes(line)) out.push(color.ok(` + ${line}`));
|
|
70
|
+
for (const line of was) if (!now.includes(line)) out.push(color.err(` - ${line}`));
|
|
71
|
+
}
|
|
72
|
+
for (const note of this.notes) out.push(color.dim(` ${note}`));
|
|
73
|
+
return out.join('\n');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
apply() {
|
|
77
|
+
for (const [path, { after }] of this.changes) {
|
|
78
|
+
const file = join(this.cwd, path);
|
|
79
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
80
|
+
writeFileSync(file, after);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/* --------------------------------------------------------- source edits */
|
|
86
|
+
|
|
87
|
+
/** Quote style of the file's existing imports, so inserted lines match. */
|
|
88
|
+
const quoteOf = (src) => (/^import\s.*"/m.test(src) && !/^import\s.*'/m.test(src) ? '"' : "'");
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Add side-effect imports (`import 'x';`) that are missing, in the given
|
|
92
|
+
* order, after the file's last top-level import (after any directive).
|
|
93
|
+
*/
|
|
94
|
+
export function addImports(src, specs) {
|
|
95
|
+
const q = quoteOf(src);
|
|
96
|
+
const missing = specs.filter((s) => !new RegExp(`['"]${escape(s)}['"]`).test(src));
|
|
97
|
+
if (!missing.length) return src;
|
|
98
|
+
// A CommonJS entry gets require() calls, after its last top-level require.
|
|
99
|
+
if (/\brequire\(/.test(src) && !/^import\s/m.test(src)) {
|
|
100
|
+
const lines = src.split('\n');
|
|
101
|
+
let at = -1;
|
|
102
|
+
lines.forEach((line, i) => {
|
|
103
|
+
if (/^(const|let|var)\s.*=\s*require\(|^require\(/.test(line.trim())) at = i;
|
|
104
|
+
});
|
|
105
|
+
lines.splice(at + 1, 0, missing.map((s) => `require(${q}${s}${q});`).join('\n'));
|
|
106
|
+
return lines.join('\n');
|
|
107
|
+
}
|
|
108
|
+
const block = missing.map((s) => `import ${q}${s}${q};`).join('\n');
|
|
109
|
+
return insertAfterImports(src, block);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Add a named import line unless the binding is already imported from that module. */
|
|
113
|
+
export function addNamedImport(src, name, from, isDefault = false) {
|
|
114
|
+
if (new RegExp(`import[^;]*\\b${name}\\b[^;]*from\\s*['"]${escape(from)}['"]`).test(src)) return src;
|
|
115
|
+
const q = quoteOf(src);
|
|
116
|
+
return insertAfterImports(src, `import ${isDefault ? name : `{ ${name} }`} from ${q}${from}${q};`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function insertAfterImports(src, block) {
|
|
120
|
+
const lines = src.split('\n');
|
|
121
|
+
let at = -1;
|
|
122
|
+
let inImport = false;
|
|
123
|
+
for (let i = 0; i < lines.length; i++) {
|
|
124
|
+
const line = lines[i].trim();
|
|
125
|
+
if (inImport) {
|
|
126
|
+
if (/from\s*['"].*['"];?$|^['"].*['"];?$/.test(line)) {
|
|
127
|
+
inImport = false;
|
|
128
|
+
at = i;
|
|
129
|
+
}
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (/^import\s/.test(line)) {
|
|
133
|
+
at = i;
|
|
134
|
+
if (!/['"];?$/.test(line)) inImport = true;
|
|
135
|
+
} else if (/^['"]use (client|server)['"];?$/.test(line) && at === -1) {
|
|
136
|
+
at = i;
|
|
137
|
+
} else if (line && !line.startsWith('//') && !line.startsWith('/*') && !line.startsWith('*') && at !== -1) {
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
lines.splice(at + 1, 0, block);
|
|
142
|
+
return lines.join('\n');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Tailwind v4: `@import "<spec>";` after the stylesheet's last @import. */
|
|
146
|
+
export function addCssImport(css, spec) {
|
|
147
|
+
if (css.includes(spec)) return css;
|
|
148
|
+
const lines = css.split('\n');
|
|
149
|
+
let at = -1;
|
|
150
|
+
lines.forEach((line, i) => {
|
|
151
|
+
if (/^@import\s/.test(line.trim())) at = i;
|
|
152
|
+
});
|
|
153
|
+
lines.splice(at + 1, 0, `@import "${spec}";`);
|
|
154
|
+
return lines.join('\n');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Tailwind v3: add the preset to `presets`, without reformatting the rest. */
|
|
158
|
+
export function addTailwindPreset(src) {
|
|
159
|
+
if (src.includes('@pinfold/css/tailwind')) return src;
|
|
160
|
+
const esm = /export\s+default/.test(src);
|
|
161
|
+
const ref = esm ? 'pinfoldPreset' : "require('@pinfold/css/tailwind')";
|
|
162
|
+
let out = src;
|
|
163
|
+
if (/presets\s*:\s*\[/.test(out)) out = out.replace(/presets\s*:\s*\[/, (m) => `${m}${ref}, `);
|
|
164
|
+
else if (/(module\.exports\s*=\s*\{|export\s+default\s*\{|defineConfig\(\s*\{)/.test(out))
|
|
165
|
+
out = out.replace(/(module\.exports\s*=\s*\{|export\s+default\s*\{|defineConfig\(\s*\{)/, (m) => `${m}\n presets: [${ref}],`);
|
|
166
|
+
else return src;
|
|
167
|
+
return esm ? addNamedImport(out, 'pinfoldPreset', '@pinfold/css/tailwind', true) : out;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
package/package.json
CHANGED
|
@@ -1,13 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pinfold",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"description": "CLI
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pinfold CLI: set up, add components, generate themes and check a project.",
|
|
5
5
|
"author": "Indranil Bhattacharya",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pinfold",
|
|
8
|
+
"cli",
|
|
9
|
+
"ui",
|
|
10
|
+
"react",
|
|
11
|
+
"design-system"
|
|
12
|
+
],
|
|
6
13
|
"license": "MIT",
|
|
7
14
|
"homepage": "https://pinfold.indranildev.in",
|
|
8
|
-
"repository": {
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/IndranilBhattacharya/pinfold.git",
|
|
18
|
+
"directory": "packages/cli"
|
|
19
|
+
},
|
|
9
20
|
"bugs": "https://github.com/IndranilBhattacharya/pinfold/issues",
|
|
10
|
-
"
|
|
11
|
-
"bin": {
|
|
12
|
-
|
|
21
|
+
"type": "module",
|
|
22
|
+
"bin": {
|
|
23
|
+
"pinfold": "bin/pinfold.mjs"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"bin",
|
|
27
|
+
"lib",
|
|
28
|
+
"templates",
|
|
29
|
+
"registry.json"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "node -e \"process.exit(0)\"",
|
|
33
|
+
"start": "node bin/pinfold.mjs"
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=20"
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@clack/prompts": "^1.8.1",
|
|
43
|
+
"@pinfold/core": "0.1.0"
|
|
44
|
+
}
|
|
13
45
|
}
|
package/registry.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://pinfold.indranildev.in/registry.schema.json",
|
|
3
|
+
"version": 1,
|
|
4
|
+
"core": {
|
|
5
|
+
"packages": ["@pinfold/react", "@pinfold/css"],
|
|
6
|
+
"styles": "@pinfold/css/core.css"
|
|
7
|
+
},
|
|
8
|
+
"components": [
|
|
9
|
+
{
|
|
10
|
+
"name": "accordion",
|
|
11
|
+
"import": "@pinfold/react/accordion",
|
|
12
|
+
"styles": "@pinfold/css/accordion.css",
|
|
13
|
+
"status": "stable",
|
|
14
|
+
"description": "Collapsible disclosure rows. WAI-ARIA accordion pattern.",
|
|
15
|
+
"variants": ["solid", "outline", "separated", "filled", "ghost"],
|
|
16
|
+
"hook": "useAccordion",
|
|
17
|
+
"snippet": "<Accordion items={[{ title: 'Shipping', content: 'Ships in 2 days.' }]} />"
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"planned": ["tabs", "dialog", "select", "combobox", "data-grid", "calendar", "date-picker", "toast", "tooltip", "command", "form", "tree"]
|
|
21
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/* Pinfold — __NAME__.
|
|
2
|
+
Two-layer variables: [data-size] writes the `*-base` preset, `dimensions`
|
|
3
|
+
writes --pinfold-__NAME__-* which wins through the var() fallback.
|
|
4
|
+
Every value resolves to a core token — a literal colour here would be
|
|
5
|
+
invisible to the palette, to dark mode and to consumer overrides, and the
|
|
6
|
+
conformance suite fails the build if one appears. */
|
|
7
|
+
|
|
8
|
+
@layer pinfold.components {
|
|
9
|
+
|
|
10
|
+
.pinfold-__NAME__ {
|
|
11
|
+
--pinfold-__CAMEL__-pad-x-base: var(--pinfold-space-4);
|
|
12
|
+
--pinfold-__CAMEL__-pad-y-base: var(--pinfold-space-2);
|
|
13
|
+
--pinfold-__CAMEL__-font-base: var(--pinfold-text-md);
|
|
14
|
+
--pinfold-__CAMEL__-radius-base: var(--pinfold-radius-md);
|
|
15
|
+
--pinfold-__CAMEL__-gap-base: var(--pinfold-space-2);
|
|
16
|
+
|
|
17
|
+
--pinfold-__CAMEL__-pad-x: var(--pinfold-__NAME__-padding-x, var(--pinfold-__CAMEL__-pad-x-base));
|
|
18
|
+
--pinfold-__CAMEL__-pad-y: var(--pinfold-__NAME__-padding-y, var(--pinfold-__CAMEL__-pad-y-base));
|
|
19
|
+
--pinfold-__CAMEL__-font: var(--pinfold-__NAME__-font-size, var(--pinfold-__CAMEL__-font-base));
|
|
20
|
+
--pinfold-__CAMEL__-radius: var(--pinfold-__NAME__-radius, var(--pinfold-__CAMEL__-radius-base));
|
|
21
|
+
--pinfold-__CAMEL__-gap: var(--pinfold-__NAME__-gap, var(--pinfold-__CAMEL__-gap-base));
|
|
22
|
+
--pinfold-__CAMEL__-border: var(--pinfold-__NAME__-border-width, 1px);
|
|
23
|
+
--pinfold-__CAMEL__-duration: var(--pinfold-__NAME__-duration, var(--pinfold-duration-fast));
|
|
24
|
+
--pinfold-__CAMEL__-accent: var(--pinfold-color-accent);
|
|
25
|
+
|
|
26
|
+
display: flex;
|
|
27
|
+
gap: var(--pinfold-__CAMEL__-gap);
|
|
28
|
+
width: var(--pinfold-__NAME__-width, auto);
|
|
29
|
+
max-width: var(--pinfold-__NAME__-max-width, none);
|
|
30
|
+
font-size: var(--pinfold-__CAMEL__-font);
|
|
31
|
+
color: var(--pinfold-color-text);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.pinfold-__NAME__[data-orientation='vertical'] {
|
|
35
|
+
flex-direction: column;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* ------------------------------------------------------------------ sizes */
|
|
39
|
+
.pinfold-__NAME__[data-size='sm'] {
|
|
40
|
+
--pinfold-__CAMEL__-pad-x-base: var(--pinfold-space-3);
|
|
41
|
+
--pinfold-__CAMEL__-pad-y-base: var(--pinfold-space-1);
|
|
42
|
+
--pinfold-__CAMEL__-font-base: var(--pinfold-text-sm);
|
|
43
|
+
}
|
|
44
|
+
.pinfold-__NAME__[data-size='lg'] {
|
|
45
|
+
--pinfold-__CAMEL__-pad-x-base: var(--pinfold-space-5);
|
|
46
|
+
--pinfold-__CAMEL__-pad-y-base: var(--pinfold-space-3);
|
|
47
|
+
--pinfold-__CAMEL__-font-base: var(--pinfold-text-lg);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* ------------------------------------------------------------------ tones */
|
|
51
|
+
.pinfold-__NAME__[data-tone='neutral'] { --pinfold-__CAMEL__-accent: var(--pinfold-color-text); }
|
|
52
|
+
.pinfold-__NAME__[data-tone='success'] { --pinfold-__CAMEL__-accent: var(--pinfold-success-500); }
|
|
53
|
+
.pinfold-__NAME__[data-tone='warning'] { --pinfold-__CAMEL__-accent: var(--pinfold-warning-500); }
|
|
54
|
+
.pinfold-__NAME__[data-tone='danger'] { --pinfold-__CAMEL__-accent: var(--pinfold-danger-500); }
|
|
55
|
+
.pinfold-__NAME__[data-tone='info'] { --pinfold-__CAMEL__-accent: var(--pinfold-info-500); }
|
|
56
|
+
|
|
57
|
+
/* ------------------------------------------------------------------- item */
|
|
58
|
+
.pinfold-__NAME____item {
|
|
59
|
+
display: inline-flex;
|
|
60
|
+
align-items: center;
|
|
61
|
+
gap: var(--pinfold-space-2);
|
|
62
|
+
padding: var(--pinfold-__CAMEL__-pad-y) var(--pinfold-__CAMEL__-pad-x);
|
|
63
|
+
font-weight: var(--pinfold-weight-medium);
|
|
64
|
+
color: var(--pinfold-color-text-muted);
|
|
65
|
+
border-radius: var(--pinfold-__CAMEL__-radius);
|
|
66
|
+
transition: background-color var(--pinfold-__CAMEL__-duration) var(--pinfold-ease),
|
|
67
|
+
color var(--pinfold-__CAMEL__-duration) var(--pinfold-ease);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.pinfold-__NAME__[data-variant='outline'] .pinfold-__NAME____item {
|
|
71
|
+
border: var(--pinfold-__CAMEL__-border) solid var(--pinfold-color-border);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.pinfold-__NAME____item:hover {
|
|
75
|
+
background: var(--pinfold-color-surface-hover);
|
|
76
|
+
color: var(--pinfold-color-text);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.pinfold-__NAME____item[data-state='selected'] {
|
|
80
|
+
color: var(--pinfold-__CAMEL__-accent);
|
|
81
|
+
background: var(--pinfold-color-surface-selected);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.pinfold-__NAME____item[data-disabled] {
|
|
85
|
+
color: var(--pinfold-color-text-disabled);
|
|
86
|
+
cursor: not-allowed;
|
|
87
|
+
}
|
|
88
|
+
.pinfold-__NAME____item[data-disabled]:hover {
|
|
89
|
+
background: none;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.pinfold-__NAME____item:focus-visible {
|
|
93
|
+
position: relative;
|
|
94
|
+
z-index: 1;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.pinfold-__NAME____label {
|
|
98
|
+
min-width: 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
4
|
+
import { describePinfoldConformance } from '@pinfold/testing';
|
|
5
|
+
import { __PASCAL__ } from './__PASCAL__';
|
|
6
|
+
import { use__PASCAL__ } from './use__PASCAL__';
|
|
7
|
+
|
|
8
|
+
const items = [
|
|
9
|
+
{ id: 'a', label: 'First' },
|
|
10
|
+
{ id: 'b', label: 'Second' },
|
|
11
|
+
{ id: 'c', label: 'Third', disabled: true },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const buttons = () => screen.getAllByRole('button');
|
|
15
|
+
|
|
16
|
+
describe('__PASCAL__', () => {
|
|
17
|
+
it('renders as a component returned from the hook', () => {
|
|
18
|
+
function Demo() {
|
|
19
|
+
const Thing = use__PASCAL__({ items, defaultValue: 'a' });
|
|
20
|
+
return <Thing className="layout-class" />;
|
|
21
|
+
}
|
|
22
|
+
render(<Demo />);
|
|
23
|
+
expect(buttons()[0]).toHaveAttribute('data-state', 'selected');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('selects on click and reports the change', () => {
|
|
27
|
+
const onValueChange = vi.fn();
|
|
28
|
+
render(<__PASCAL__ items={items} onValueChange={onValueChange} />);
|
|
29
|
+
|
|
30
|
+
fireEvent.click(buttons()[1]);
|
|
31
|
+
expect(onValueChange).toHaveBeenLastCalledWith('b');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('ignores disabled items but keeps them focusable', () => {
|
|
35
|
+
render(<__PASCAL__ items={items} />);
|
|
36
|
+
const third = buttons()[2];
|
|
37
|
+
|
|
38
|
+
expect(third).toHaveAttribute('aria-disabled', 'true');
|
|
39
|
+
expect(third).not.toHaveAttribute('disabled');
|
|
40
|
+
fireEvent.click(third);
|
|
41
|
+
expect(third).toHaveAttribute('data-state', 'idle');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('moves focus with the arrow keys', () => {
|
|
45
|
+
render(<__PASCAL__ items={items} />);
|
|
46
|
+
const [first, second] = buttons();
|
|
47
|
+
|
|
48
|
+
first.focus();
|
|
49
|
+
fireEvent.keyDown(first, { key: 'ArrowRight' });
|
|
50
|
+
expect(document.activeElement).toBe(second);
|
|
51
|
+
|
|
52
|
+
fireEvent.keyDown(second, { key: 'Home' });
|
|
53
|
+
expect(document.activeElement).toBe(first);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// TODO: add the keyboard map and roles your WAI-ARIA pattern requires.
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describePinfoldConformance({
|
|
60
|
+
name: '__NAME__',
|
|
61
|
+
slots: ['root', 'item', 'label'],
|
|
62
|
+
dimension: { key: 'paddingX', value: 24 },
|
|
63
|
+
cssFile: 'packages/css/src/__NAME__.css',
|
|
64
|
+
hook: { use: use__PASCAL__ as any, config: { items } },
|
|
65
|
+
render: (config) => <__PASCAL__ items={items} defaultValue="a" {...config} />,
|
|
66
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type * as React from 'react';
|
|
2
|
+
import { use__PASCAL__ } from './use__PASCAL__';
|
|
3
|
+
import type { __PASCAL__Config } from './types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Thin JSX wrapper over `use__PASCAL__().render()`, for people who prefer a
|
|
7
|
+
* tag. It adds nothing the hook does not already do.
|
|
8
|
+
*/
|
|
9
|
+
export function __PASCAL__(config: __PASCAL__Config): React.ReactElement {
|
|
10
|
+
return use__PASCAL__(config).render();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
__PASCAL__.displayName = '__PASCAL__';
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { cn, dimensionVars, mergeProps, resolveNode } from '@pinfold/core';
|
|
3
|
+
import { renderSlot, slotProps } from '../slots';
|
|
4
|
+
import type {
|
|
5
|
+
Resolved__PASCAL__Appearance,
|
|
6
|
+
__PASCAL__Api,
|
|
7
|
+
__PASCAL__Config,
|
|
8
|
+
__PASCAL__ItemState,
|
|
9
|
+
__PASCAL__Node,
|
|
10
|
+
__PASCAL__RootProps,
|
|
11
|
+
__PASCAL__Slot,
|
|
12
|
+
} from './types';
|
|
13
|
+
|
|
14
|
+
const B = 'pinfold-__NAME__';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Everything the markup needs, gathered once per render.
|
|
18
|
+
*
|
|
19
|
+
* These are plain functions rather than components on purpose: the hook owns
|
|
20
|
+
* rendering, so `use__PASCAL__()` can hand back a component instead of being
|
|
21
|
+
* called by one.
|
|
22
|
+
*/
|
|
23
|
+
export interface ViewContext {
|
|
24
|
+
api: __PASCAL__Api;
|
|
25
|
+
config: __PASCAL__Config;
|
|
26
|
+
a: Resolved__PASCAL__Appearance;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Component class, unless the consumer opted out of our stylesheet. */
|
|
30
|
+
const cls = (ctx: ViewContext, name: string) => (ctx.a.unstyled ? undefined : name);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Fold three layers of overrides into one props bag, in ascending priority:
|
|
34
|
+
* the component's own class, the root config's slot overrides, then the row's
|
|
35
|
+
* own overrides.
|
|
36
|
+
*/
|
|
37
|
+
function slot<S extends __PASCAL__Slot>(
|
|
38
|
+
ctx: ViewContext,
|
|
39
|
+
name: S,
|
|
40
|
+
base: Record<string, any>,
|
|
41
|
+
item?: __PASCAL__ItemState,
|
|
42
|
+
): Record<string, any> {
|
|
43
|
+
const merged = slotProps<__PASCAL__Slot, __PASCAL__ItemState>(ctx.config, name, base);
|
|
44
|
+
|
|
45
|
+
const perItem = item?.config;
|
|
46
|
+
if (!perItem?.classNames?.[name] && !perItem?.styles?.[name]) return merged;
|
|
47
|
+
|
|
48
|
+
return mergeProps(merged, {
|
|
49
|
+
className: perItem.classNames?.[name],
|
|
50
|
+
style: perItem.styles?.[name],
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function renderRoot(ctx: ViewContext, rootProps?: __PASCAL__RootProps): React.ReactElement {
|
|
55
|
+
const { api, config, a } = ctx;
|
|
56
|
+
|
|
57
|
+
const props = api.getRootProps(
|
|
58
|
+
mergeProps(
|
|
59
|
+
slot(ctx, 'root', {
|
|
60
|
+
className: a.unstyled ? config.className : cn('pinfold', B, config.className),
|
|
61
|
+
style: { ...dimensionVars('__NAME__', config.dimensions), ...config.style },
|
|
62
|
+
'data-variant': a.variant,
|
|
63
|
+
'data-size': a.size,
|
|
64
|
+
'data-tone': a.tone,
|
|
65
|
+
'data-pinfold-radius': a.radius,
|
|
66
|
+
'data-animate': a.animate ? '' : undefined,
|
|
67
|
+
'data-unstyled': a.unstyled ? '' : undefined,
|
|
68
|
+
}),
|
|
69
|
+
rootProps as Record<string, any>,
|
|
70
|
+
),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
return <div {...props}>{api.items.map((item) => renderItem(ctx, item))}</div>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function renderItem(
|
|
77
|
+
ctx: ViewContext,
|
|
78
|
+
item: __PASCAL__ItemState,
|
|
79
|
+
extra?: Record<string, any>,
|
|
80
|
+
): React.ReactElement {
|
|
81
|
+
const { api, config } = ctx;
|
|
82
|
+
|
|
83
|
+
const props = api.getItemProps(
|
|
84
|
+
item,
|
|
85
|
+
mergeProps(slot(ctx, 'item', { className: cls(ctx, `${B}__item`) }, item), extra ?? {}),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const label = (
|
|
89
|
+
<span {...slot(ctx, 'label', { className: cls(ctx, `${B}__label`) }, item)}>
|
|
90
|
+
{resolveNode(item.config.label as __PASCAL__Node, item)}
|
|
91
|
+
</span>
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
if (config.renderItem) {
|
|
95
|
+
return (
|
|
96
|
+
<React.Fragment key={item.id}>
|
|
97
|
+
{config.renderItem({ state: item, props, children: label })}
|
|
98
|
+
</React.Fragment>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<React.Fragment key={item.id}>
|
|
104
|
+
{renderSlot<__PASCAL__Slot, __PASCAL__ItemState>(
|
|
105
|
+
config,
|
|
106
|
+
'item',
|
|
107
|
+
props,
|
|
108
|
+
item,
|
|
109
|
+
label,
|
|
110
|
+
(p, children) => <button {...p}>{children}</button>,
|
|
111
|
+
)}
|
|
112
|
+
</React.Fragment>
|
|
113
|
+
);
|
|
114
|
+
}
|