@nqmcreative/ui 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/LICENSE +21 -0
- package/README.md +507 -0
- package/cli/index.mjs +410 -0
- package/dist/actions/anchor.d.ts +29 -0
- package/dist/actions/anchor.js +122 -0
- package/dist/actions/dismissable.d.ts +44 -0
- package/dist/actions/dismissable.js +147 -0
- package/dist/components/Accordion.svelte +14 -0
- package/dist/components/Accordion.svelte.d.ts +8 -0
- package/dist/components/AccordionItem.svelte +46 -0
- package/dist/components/AccordionItem.svelte.d.ts +14 -0
- package/dist/components/Alert.svelte +88 -0
- package/dist/components/Alert.svelte.d.ts +18 -0
- package/dist/components/Avatar.svelte +58 -0
- package/dist/components/Avatar.svelte.d.ts +16 -0
- package/dist/components/AvatarGroup.svelte +17 -0
- package/dist/components/AvatarGroup.svelte.d.ts +8 -0
- package/dist/components/Badge.svelte +53 -0
- package/dist/components/Badge.svelte.d.ts +18 -0
- package/dist/components/Breadcrumb.svelte +46 -0
- package/dist/components/Breadcrumb.svelte.d.ts +14 -0
- package/dist/components/Button.svelte +94 -0
- package/dist/components/Button.svelte.d.ts +21 -0
- package/dist/components/Calendar.svelte +206 -0
- package/dist/components/Calendar.svelte.d.ts +22 -0
- package/dist/components/Card.svelte +76 -0
- package/dist/components/Card.svelte.d.ts +21 -0
- package/dist/components/Checkbox.svelte +69 -0
- package/dist/components/Checkbox.svelte.d.ts +15 -0
- package/dist/components/CheckboxGroup.svelte +109 -0
- package/dist/components/CheckboxGroup.svelte.d.ts +30 -0
- package/dist/components/Combobox.svelte +268 -0
- package/dist/components/Combobox.svelte.d.ts +29 -0
- package/dist/components/CommandPalette.svelte +226 -0
- package/dist/components/CommandPalette.svelte.d.ts +32 -0
- package/dist/components/ConfirmDialog.svelte +97 -0
- package/dist/components/ConfirmDialog.svelte.d.ts +22 -0
- package/dist/components/ContextMenu.svelte +90 -0
- package/dist/components/ContextMenu.svelte.d.ts +15 -0
- package/dist/components/DatePicker.svelte +186 -0
- package/dist/components/DatePicker.svelte.d.ts +23 -0
- package/dist/components/Divider.svelte +30 -0
- package/dist/components/Divider.svelte.d.ts +10 -0
- package/dist/components/Drawer.svelte +161 -0
- package/dist/components/Drawer.svelte.d.ts +20 -0
- package/dist/components/Dropdown.svelte +112 -0
- package/dist/components/Dropdown.svelte.d.ts +23 -0
- package/dist/components/Dropzone.svelte +226 -0
- package/dist/components/Dropzone.svelte.d.ts +32 -0
- package/dist/components/EmptyState.svelte +57 -0
- package/dist/components/EmptyState.svelte.d.ts +18 -0
- package/dist/components/Field.svelte +42 -0
- package/dist/components/Field.svelte.d.ts +16 -0
- package/dist/components/Footer.svelte +93 -0
- package/dist/components/Footer.svelte.d.ts +25 -0
- package/dist/components/Input.svelte +60 -0
- package/dist/components/Input.svelte.d.ts +18 -0
- package/dist/components/InputGroup.svelte +19 -0
- package/dist/components/InputGroup.svelte.d.ts +10 -0
- package/dist/components/Kbd.svelte +17 -0
- package/dist/components/Kbd.svelte.d.ts +8 -0
- package/dist/components/Label.svelte +16 -0
- package/dist/components/Label.svelte.d.ts +9 -0
- package/dist/components/Link.svelte +44 -0
- package/dist/components/Link.svelte.d.ts +16 -0
- package/dist/components/LocaleProvider.svelte +24 -0
- package/dist/components/LocaleProvider.svelte.d.ts +10 -0
- package/dist/components/MenuItem.svelte +58 -0
- package/dist/components/MenuItem.svelte.d.ts +18 -0
- package/dist/components/MenuSeparator.svelte +22 -0
- package/dist/components/MenuSeparator.svelte.d.ts +8 -0
- package/dist/components/Modal.svelte +114 -0
- package/dist/components/Modal.svelte.d.ts +21 -0
- package/dist/components/MultiSelect.svelte +292 -0
- package/dist/components/MultiSelect.svelte.d.ts +31 -0
- package/dist/components/Navbar.svelte +190 -0
- package/dist/components/Navbar.svelte.d.ts +33 -0
- package/dist/components/NumberInput.svelte +100 -0
- package/dist/components/NumberInput.svelte.d.ts +15 -0
- package/dist/components/Pagination.svelte +90 -0
- package/dist/components/Pagination.svelte.d.ts +15 -0
- package/dist/components/PasswordInput.svelte +104 -0
- package/dist/components/PasswordInput.svelte.d.ts +12 -0
- package/dist/components/Popover.svelte +114 -0
- package/dist/components/Popover.svelte.d.ts +19 -0
- package/dist/components/Progress.svelte +56 -0
- package/dist/components/Progress.svelte.d.ts +17 -0
- package/dist/components/Radio.svelte +55 -0
- package/dist/components/Radio.svelte.d.ts +15 -0
- package/dist/components/RadioGroup.svelte +93 -0
- package/dist/components/RadioGroup.svelte.d.ts +27 -0
- package/dist/components/SegmentedControl.svelte +88 -0
- package/dist/components/SegmentedControl.svelte.d.ts +25 -0
- package/dist/components/Select.svelte +61 -0
- package/dist/components/Select.svelte.d.ts +20 -0
- package/dist/components/Sidebar.svelte +206 -0
- package/dist/components/Sidebar.svelte.d.ts +36 -0
- package/dist/components/Skeleton.svelte +46 -0
- package/dist/components/Skeleton.svelte.d.ts +13 -0
- package/dist/components/Slider.svelte +79 -0
- package/dist/components/Slider.svelte.d.ts +19 -0
- package/dist/components/Spinner.svelte +34 -0
- package/dist/components/Spinner.svelte.d.ts +13 -0
- package/dist/components/Stat.svelte +49 -0
- package/dist/components/Stat.svelte.d.ts +16 -0
- package/dist/components/Steps.svelte +136 -0
- package/dist/components/Steps.svelte.d.ts +24 -0
- package/dist/components/Switch.svelte +64 -0
- package/dist/components/Switch.svelte.d.ts +14 -0
- package/dist/components/Table.svelte +236 -0
- package/dist/components/Table.svelte.d.ts +70 -0
- package/dist/components/Tabs.svelte +91 -0
- package/dist/components/Tabs.svelte.d.ts +23 -0
- package/dist/components/Textarea.svelte +54 -0
- package/dist/components/Textarea.svelte.d.ts +12 -0
- package/dist/components/ThemeToggle.svelte +133 -0
- package/dist/components/ThemeToggle.svelte.d.ts +20 -0
- package/dist/components/Toaster.svelte +110 -0
- package/dist/components/Toaster.svelte.d.ts +8 -0
- package/dist/components/Tooltip.svelte +33 -0
- package/dist/components/Tooltip.svelte.d.ts +12 -0
- package/dist/date.d.ts +35 -0
- package/dist/date.js +108 -0
- package/dist/fonts.css +193 -0
- package/dist/index.d.ts +105 -0
- package/dist/index.js +77 -0
- package/dist/locale.svelte.d.ts +93 -0
- package/dist/locale.svelte.js +153 -0
- package/dist/theme.css +210 -0
- package/dist/toast.svelte.d.ts +47 -0
- package/dist/toast.svelte.js +60 -0
- package/dist/tones.d.ts +48 -0
- package/dist/tones.js +209 -0
- package/package.json +349 -0
- package/registry.json +669 -0
package/cli/index.mjs
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @nqmcreative/ui CLI
|
|
4
|
+
*
|
|
5
|
+
* nqm-ui init wire the design system into this project
|
|
6
|
+
* nqm-ui add <name...> print (or insert) the import for a component
|
|
7
|
+
* nqm-ui list [category] every component and its subpath
|
|
8
|
+
* nqm-ui info <name> what one component pulls in
|
|
9
|
+
*
|
|
10
|
+
* Node built-ins only, so `bunx @nqmcreative/ui init` needs nothing installed.
|
|
11
|
+
* Every write is idempotent: running init twice changes nothing the second
|
|
12
|
+
* time.
|
|
13
|
+
*/
|
|
14
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
15
|
+
import { existsSync } from 'node:fs';
|
|
16
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
|
|
19
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const cwd = process.cwd();
|
|
21
|
+
|
|
22
|
+
const argv = process.argv.slice(2);
|
|
23
|
+
const flags = new Set(argv.filter((a) => a.startsWith('--')));
|
|
24
|
+
|
|
25
|
+
/** Flags that take a value, so the value is not mistaken for a positional. */
|
|
26
|
+
const valueFlags = new Set(['--to']);
|
|
27
|
+
const args = [];
|
|
28
|
+
for (let i = 0; i < argv.length; i++) {
|
|
29
|
+
if (argv[i].startsWith('--')) {
|
|
30
|
+
if (valueFlags.has(argv[i])) i++;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
args.push(argv[i]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const command = args[0];
|
|
37
|
+
const dryRun = flags.has('--dry-run');
|
|
38
|
+
|
|
39
|
+
/* ---------------------------------------------------------------- output -- */
|
|
40
|
+
|
|
41
|
+
const c = {
|
|
42
|
+
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
43
|
+
bold: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
44
|
+
green: (s) => `\x1b[32m${s}\x1b[0m`,
|
|
45
|
+
yellow: (s) => `\x1b[33m${s}\x1b[0m`,
|
|
46
|
+
red: (s) => `\x1b[31m${s}\x1b[0m`,
|
|
47
|
+
cyan: (s) => `\x1b[36m${s}\x1b[0m`
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const log = (msg = '') => console.log(msg);
|
|
51
|
+
const ok = (msg) => log(`${c.green('✓')} ${msg}`);
|
|
52
|
+
const skip = (msg) => log(`${c.dim('·')} ${c.dim(msg)}`);
|
|
53
|
+
const warn = (msg) => log(`${c.yellow('!')} ${msg}`);
|
|
54
|
+
const fail = (msg) => {
|
|
55
|
+
console.error(`${c.red('✗')} ${msg}`);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/* -------------------------------------------------------------- registry -- */
|
|
60
|
+
|
|
61
|
+
async function registry() {
|
|
62
|
+
// Sits next to the CLI once packed, one level up in the repo.
|
|
63
|
+
for (const path of [join(here, 'registry.json'), join(here, '..', 'registry.json')]) {
|
|
64
|
+
if (existsSync(path)) return JSON.parse(await readFile(path, 'utf8'));
|
|
65
|
+
}
|
|
66
|
+
fail('registry.json not found — is the package installed correctly?');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Accepts `Button`, `button`, `date-picker` or the full subpath. */
|
|
70
|
+
function findComponent(list, query) {
|
|
71
|
+
const needle = query.toLowerCase().replace(/^@nqmcreative\/ui\//, '');
|
|
72
|
+
return list.find(
|
|
73
|
+
(item) =>
|
|
74
|
+
item.name.toLowerCase() === needle ||
|
|
75
|
+
item.subpath.endsWith(`/${needle}`) ||
|
|
76
|
+
item.subpath === query
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* ------------------------------------------------------------ file edits -- */
|
|
81
|
+
|
|
82
|
+
async function readOr(path, fallback = '') {
|
|
83
|
+
try {
|
|
84
|
+
return await readFile(path, 'utf8');
|
|
85
|
+
} catch {
|
|
86
|
+
return fallback;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function write(path, content, label) {
|
|
91
|
+
if (dryRun) {
|
|
92
|
+
skip(`would write ${label ?? relative(cwd, path)}`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
await mkdir(dirname(path), { recursive: true });
|
|
96
|
+
await writeFile(path, content, 'utf8');
|
|
97
|
+
ok(label ?? relative(cwd, path));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* -------------------------------------------------------------- detection -- */
|
|
101
|
+
|
|
102
|
+
async function detectProject() {
|
|
103
|
+
const pkgPath = join(cwd, 'package.json');
|
|
104
|
+
if (!existsSync(pkgPath)) fail('no package.json here — run this inside your project');
|
|
105
|
+
|
|
106
|
+
const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
|
|
107
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
108
|
+
|
|
109
|
+
const isKit = '@sveltejs/kit' in deps;
|
|
110
|
+
const hasTailwind = '@tailwindcss/vite' in deps || 'tailwindcss' in deps;
|
|
111
|
+
const hasUi = '@nqmcreative/ui' in deps;
|
|
112
|
+
|
|
113
|
+
// Where the Tailwind entry CSS lives, or should.
|
|
114
|
+
const candidates = isKit
|
|
115
|
+
? ['src/app.css', 'src/routes/+layout.css', 'src/app.postcss']
|
|
116
|
+
: ['src/app.css', 'src/style.css', 'src/main.css'];
|
|
117
|
+
const css = candidates.find((path) => existsSync(join(cwd, path))) ?? candidates[0];
|
|
118
|
+
|
|
119
|
+
return { pkg, deps, isKit, hasTailwind, hasUi, css: join(cwd, css), cssRel: css };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** How many `../` from the CSS file up to node_modules. */
|
|
123
|
+
function sourcePath(cssRel) {
|
|
124
|
+
const depth = cssRel.split(/[\\/]/).length - 1;
|
|
125
|
+
return `${'../'.repeat(depth) || './'}node_modules/@nqmcreative/ui/dist`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/* ------------------------------------------------------------------ init -- */
|
|
129
|
+
|
|
130
|
+
async function init() {
|
|
131
|
+
const project = await detectProject();
|
|
132
|
+
|
|
133
|
+
log();
|
|
134
|
+
log(c.bold(' @nqmcreative/ui'));
|
|
135
|
+
log(c.dim(` ${project.isKit ? 'SvelteKit' : 'Svelte + Vite'} project detected`));
|
|
136
|
+
log();
|
|
137
|
+
|
|
138
|
+
if (!project.hasUi) {
|
|
139
|
+
warn('the package is not installed yet — add it first:');
|
|
140
|
+
log(c.cyan(' bun add @nqmcreative/ui'));
|
|
141
|
+
log();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!project.hasTailwind) {
|
|
145
|
+
warn('Tailwind CSS v4 is not installed:');
|
|
146
|
+
log(c.cyan(' bun add -d tailwindcss @tailwindcss/vite'));
|
|
147
|
+
log();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/* --- 1. the entry CSS --- */
|
|
151
|
+
const source = sourcePath(project.cssRel);
|
|
152
|
+
const lines = [
|
|
153
|
+
`@import 'tailwindcss';`,
|
|
154
|
+
`@import '@nqmcreative/ui/theme.css';`,
|
|
155
|
+
`@import '@nqmcreative/ui/fonts.css';`,
|
|
156
|
+
``,
|
|
157
|
+
`/* Tailwind v4 skips node_modules — point it at the package's dist so`,
|
|
158
|
+
` the class names used inside the components are generated. */`,
|
|
159
|
+
`@source '${source}';`
|
|
160
|
+
];
|
|
161
|
+
|
|
162
|
+
const css = await readOr(project.css);
|
|
163
|
+
if (css.includes('@nqmcreative/ui/theme.css')) {
|
|
164
|
+
skip(`${project.cssRel} already imports the theme`);
|
|
165
|
+
} else {
|
|
166
|
+
const body = css.trim()
|
|
167
|
+
? `${lines.join('\n')}\n\n${css.replace(/@import 'tailwindcss';\n?/, '').trim()}\n`
|
|
168
|
+
: `${lines.join('\n')}\n`;
|
|
169
|
+
await write(project.css, body, project.cssRel);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/* --- 2. app.html: font preconnect + no-flash theme --- */
|
|
173
|
+
if (project.isKit) {
|
|
174
|
+
const htmlPath = join(cwd, 'src/app.html');
|
|
175
|
+
let html = await readOr(htmlPath);
|
|
176
|
+
if (!html) {
|
|
177
|
+
// Some `sv create` templates omit it and let SvelteKit fall back to a
|
|
178
|
+
// built-in. Write the standard one so there is somewhere to put the
|
|
179
|
+
// preconnect and the no-flash theme script.
|
|
180
|
+
html = [
|
|
181
|
+
'<!doctype html>',
|
|
182
|
+
'<html lang="en">',
|
|
183
|
+
' <head>',
|
|
184
|
+
' <meta charset="utf-8" />',
|
|
185
|
+
' <meta name="viewport" content="width=device-width, initial-scale=1" />',
|
|
186
|
+
' %sveltekit.head%',
|
|
187
|
+
' </head>',
|
|
188
|
+
' <body data-sveltekit-preload-data="hover">',
|
|
189
|
+
' <div style="display: contents">%sveltekit.body%</div>',
|
|
190
|
+
' </body>',
|
|
191
|
+
'</html>',
|
|
192
|
+
''
|
|
193
|
+
].join('\n');
|
|
194
|
+
skip('src/app.html was missing — creating it');
|
|
195
|
+
}
|
|
196
|
+
{
|
|
197
|
+
// Unindented — `%sveltekit.head%` already sits at its own indentation,
|
|
198
|
+
// so the join below supplies it and the first line does not double up.
|
|
199
|
+
const additions = [];
|
|
200
|
+
if (!html.includes('fonts.gstatic.com')) {
|
|
201
|
+
additions.push(`<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />`);
|
|
202
|
+
}
|
|
203
|
+
if (!html.includes('nqm-theme')) {
|
|
204
|
+
additions.push(
|
|
205
|
+
`<script>`,
|
|
206
|
+
`\tconst saved = localStorage.getItem('nqm-theme');`,
|
|
207
|
+
`\tif (saved === 'dark' || saved === 'light') document.documentElement.classList.add(saved);`,
|
|
208
|
+
`</script>`
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
if (additions.length === 0) {
|
|
212
|
+
skip('src/app.html already wired');
|
|
213
|
+
} else if (!html.includes('%sveltekit.head%')) {
|
|
214
|
+
warn('src/app.html has no %sveltekit.head% — add these by hand:');
|
|
215
|
+
log(additions.map((line) => `\t\t${line}`).join('\n'));
|
|
216
|
+
} else {
|
|
217
|
+
const indent = /([ \t]*)%sveltekit\.head%/.exec(html)?.[1] ?? '\t\t';
|
|
218
|
+
html = html.replace(
|
|
219
|
+
'%sveltekit.head%',
|
|
220
|
+
`${additions.join(`\n${indent}`)}\n${indent}%sveltekit.head%`
|
|
221
|
+
);
|
|
222
|
+
await write(htmlPath, html, 'src/app.html');
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/* --- 3. remind about the vite plugin, which we will not rewrite --- */
|
|
228
|
+
const viteConfig =
|
|
229
|
+
['vite.config.ts', 'vite.config.js'].map((f) => join(cwd, f)).find((f) => existsSync(f)) ??
|
|
230
|
+
null;
|
|
231
|
+
const viteSource = viteConfig ? await readOr(viteConfig) : '';
|
|
232
|
+
if (!viteSource.includes('@tailwindcss/vite')) {
|
|
233
|
+
log();
|
|
234
|
+
warn(
|
|
235
|
+
`add the Tailwind plugin to ${viteConfig ? relative(cwd, viteConfig) : 'your vite config'} — before sveltekit():`
|
|
236
|
+
);
|
|
237
|
+
log(c.cyan(` import tailwindcss from '@tailwindcss/vite';`));
|
|
238
|
+
log(c.cyan(` plugins: [tailwindcss(), sveltekit()]`));
|
|
239
|
+
} else {
|
|
240
|
+
skip('vite config already has the Tailwind plugin');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/* --- 4. import the CSS once --- */
|
|
244
|
+
if (project.isKit) {
|
|
245
|
+
const layoutPath = join(cwd, 'src/routes/+layout.svelte');
|
|
246
|
+
let layout = await readOr(layoutPath);
|
|
247
|
+
const line = `import '../app.css';`;
|
|
248
|
+
|
|
249
|
+
if (layout.includes('app.css')) {
|
|
250
|
+
skip('src/routes/+layout.svelte already imports the CSS');
|
|
251
|
+
} else if (!layout.trim()) {
|
|
252
|
+
await write(
|
|
253
|
+
layoutPath,
|
|
254
|
+
`<script lang="ts">
|
|
255
|
+
${line}
|
|
256
|
+
|
|
257
|
+
let { children } = $props();
|
|
258
|
+
</script>
|
|
259
|
+
|
|
260
|
+
{@render children()}
|
|
261
|
+
`,
|
|
262
|
+
'src/routes/+layout.svelte'
|
|
263
|
+
);
|
|
264
|
+
} else if (/<script[^>]*>/.test(layout)) {
|
|
265
|
+
layout = layout.replace(/(<script[^>]*>\n)/, `$1\t${line}\n`);
|
|
266
|
+
await write(layoutPath, layout, 'src/routes/+layout.svelte');
|
|
267
|
+
} else {
|
|
268
|
+
log();
|
|
269
|
+
warn('import the CSS once, at the top of src/routes/+layout.svelte:');
|
|
270
|
+
log(c.cyan(` ${line}`));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
log();
|
|
275
|
+
log(` Next: ${c.cyan('bunx @nqmcreative/ui add button')}`);
|
|
276
|
+
log();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/* ------------------------------------------------------------------- add -- */
|
|
280
|
+
|
|
281
|
+
async function add() {
|
|
282
|
+
const { components } = await registry();
|
|
283
|
+
const names = args.slice(1);
|
|
284
|
+
if (names.length === 0) fail('which component? e.g. `nqm-ui add button dialog`');
|
|
285
|
+
|
|
286
|
+
const found = [];
|
|
287
|
+
for (const name of names) {
|
|
288
|
+
const item = findComponent(components, name);
|
|
289
|
+
if (!item) {
|
|
290
|
+
const close = components
|
|
291
|
+
.filter((c) => c.name.toLowerCase().startsWith(name.slice(0, 3).toLowerCase()))
|
|
292
|
+
.slice(0, 3)
|
|
293
|
+
.map((c) => c.subpath.split('/').pop());
|
|
294
|
+
fail(
|
|
295
|
+
`unknown component "${name}"${close.length ? ` — did you mean ${close.join(', ')}?` : ''}`
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
found.push(item);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const imports = found.map((item) => `import ${item.name} from '${item.subpath}';`);
|
|
302
|
+
|
|
303
|
+
// `--to <file>` inserts the imports into an existing Svelte file.
|
|
304
|
+
const toIndex = argv.indexOf('--to');
|
|
305
|
+
const target = toIndex !== -1 ? argv[toIndex + 1] : null;
|
|
306
|
+
|
|
307
|
+
if (target) {
|
|
308
|
+
const path = resolve(cwd, target);
|
|
309
|
+
if (!existsSync(path)) fail(`${target} not found`);
|
|
310
|
+
let source = await readFile(path, 'utf8');
|
|
311
|
+
|
|
312
|
+
const missing = imports.filter((line) => !source.includes(line));
|
|
313
|
+
if (missing.length === 0) {
|
|
314
|
+
skip(`${target} already imports ${found.map((f) => f.name).join(', ')}`);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (/<script[^>]*>/.test(source)) {
|
|
319
|
+
source = source.replace(/(<script[^>]*>\n)/, `$1\t${missing.join('\n\t')}\n`);
|
|
320
|
+
} else {
|
|
321
|
+
source = `<script lang="ts">\n\t${missing.join('\n\t')}\n</script>\n\n${source}`;
|
|
322
|
+
}
|
|
323
|
+
await write(path, source, target);
|
|
324
|
+
} else {
|
|
325
|
+
log();
|
|
326
|
+
log(imports.join('\n'));
|
|
327
|
+
log();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const pulled = [...new Set(found.flatMap((item) => item.uses))].filter(
|
|
331
|
+
(name) => !found.some((f) => f.name === name)
|
|
332
|
+
);
|
|
333
|
+
if (pulled.length) {
|
|
334
|
+
log(c.dim(` renders ${pulled.join(', ')} internally — nothing to install`));
|
|
335
|
+
log();
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/* ------------------------------------------------------------------ list -- */
|
|
340
|
+
|
|
341
|
+
async function list() {
|
|
342
|
+
const { components, count } = await registry();
|
|
343
|
+
const filter = args[1]?.toLowerCase();
|
|
344
|
+
|
|
345
|
+
const groups = {};
|
|
346
|
+
for (const item of components) {
|
|
347
|
+
if (filter && !item.category.toLowerCase().includes(filter)) continue;
|
|
348
|
+
(groups[item.category] ??= []).push(item);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const shown = Object.values(groups).flat().length;
|
|
352
|
+
if (shown === 0) fail(`no components in category "${filter}"`);
|
|
353
|
+
|
|
354
|
+
log();
|
|
355
|
+
for (const [category, items] of Object.entries(groups)) {
|
|
356
|
+
log(` ${c.bold(category)}`);
|
|
357
|
+
for (const item of items) {
|
|
358
|
+
log(` ${item.subpath.split('/').pop().padEnd(20)} ${c.dim(item.subpath)}`);
|
|
359
|
+
}
|
|
360
|
+
log();
|
|
361
|
+
}
|
|
362
|
+
log(c.dim(` ${shown} of ${count} components`));
|
|
363
|
+
log();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/* ------------------------------------------------------------------ info -- */
|
|
367
|
+
|
|
368
|
+
async function info() {
|
|
369
|
+
const { components } = await registry();
|
|
370
|
+
const item = findComponent(components, args[1] ?? '');
|
|
371
|
+
if (!item) fail(`unknown component "${args[1] ?? ''}" — try \`nqm-ui list\``);
|
|
372
|
+
|
|
373
|
+
log();
|
|
374
|
+
log(` ${c.bold(item.name)} ${c.dim(item.category)}`);
|
|
375
|
+
if (item.description) log(` ${item.description}`);
|
|
376
|
+
log();
|
|
377
|
+
log(` ${c.cyan(`import ${item.name} from '${item.subpath}';`)}`);
|
|
378
|
+
log();
|
|
379
|
+
if (item.uses.length) log(` renders ${item.uses.join(', ')}`);
|
|
380
|
+
if (item.modules.length) log(` uses ${item.modules.join(', ')}`);
|
|
381
|
+
log(` source ${item.file}`);
|
|
382
|
+
log();
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/* ------------------------------------------------------------------ help -- */
|
|
386
|
+
|
|
387
|
+
function help() {
|
|
388
|
+
log(`
|
|
389
|
+
${c.bold('@nqmcreative/ui')}
|
|
390
|
+
|
|
391
|
+
${c.cyan('init')} wire the design system into this project
|
|
392
|
+
${c.cyan('add <name...>')} print the import for a component
|
|
393
|
+
${c.dim('--to <file>')} …and insert it into that file instead
|
|
394
|
+
${c.cyan('list [category]')} every component and its subpath
|
|
395
|
+
${c.cyan('info <name>')} what one component pulls in
|
|
396
|
+
|
|
397
|
+
${c.dim('--dry-run')} show the writes without making them
|
|
398
|
+
`);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/* ------------------------------------------------------------------ main -- */
|
|
402
|
+
|
|
403
|
+
const commands = { init, add, list, info };
|
|
404
|
+
if (!command || flags.has('--help') || command === 'help') {
|
|
405
|
+
help();
|
|
406
|
+
} else if (commands[command]) {
|
|
407
|
+
await commands[command]();
|
|
408
|
+
} else {
|
|
409
|
+
fail(`unknown command "${command}" — try \`nqm-ui --help\``);
|
|
410
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Positions a floating element against an anchor, without a dependency.
|
|
3
|
+
*
|
|
4
|
+
* The element is taken out of flow (`position: fixed`) so it never gets clipped
|
|
5
|
+
* by an ancestor's `overflow: hidden`, then placed on the requested side. If it
|
|
6
|
+
* doesn't fit there it flips to the opposite side; if it still overhangs, it is
|
|
7
|
+
* clamped inside the viewport with `padding` to spare.
|
|
8
|
+
*
|
|
9
|
+
* Re-runs on scroll (capture phase, so nested scrollers count), on resize, and
|
|
10
|
+
* whenever the anchor or the element itself changes size.
|
|
11
|
+
*/
|
|
12
|
+
export type Placement = 'top' | 'top-start' | 'top-end' | 'bottom' | 'bottom-start' | 'bottom-end' | 'left' | 'right';
|
|
13
|
+
export interface AnchorOptions {
|
|
14
|
+
/** The element to position against. */
|
|
15
|
+
anchor?: HTMLElement | null;
|
|
16
|
+
placement?: Placement;
|
|
17
|
+
/** Gap between anchor and element, in px. */
|
|
18
|
+
offset?: number;
|
|
19
|
+
/** Minimum distance to the viewport edge, in px. */
|
|
20
|
+
padding?: number;
|
|
21
|
+
/** Force the element to the anchor's width — dropdown menus, comboboxes. */
|
|
22
|
+
matchWidth?: boolean;
|
|
23
|
+
/** Skip all work while false (the element is usually not rendered then). */
|
|
24
|
+
enabled?: boolean;
|
|
25
|
+
}
|
|
26
|
+
export declare function anchored(node: HTMLElement, options?: AnchorOptions): {
|
|
27
|
+
update(next: AnchorOptions): void;
|
|
28
|
+
destroy(): void;
|
|
29
|
+
};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Positions a floating element against an anchor, without a dependency.
|
|
3
|
+
*
|
|
4
|
+
* The element is taken out of flow (`position: fixed`) so it never gets clipped
|
|
5
|
+
* by an ancestor's `overflow: hidden`, then placed on the requested side. If it
|
|
6
|
+
* doesn't fit there it flips to the opposite side; if it still overhangs, it is
|
|
7
|
+
* clamped inside the viewport with `padding` to spare.
|
|
8
|
+
*
|
|
9
|
+
* Re-runs on scroll (capture phase, so nested scrollers count), on resize, and
|
|
10
|
+
* whenever the anchor or the element itself changes size.
|
|
11
|
+
*/
|
|
12
|
+
const defaults = {
|
|
13
|
+
placement: 'bottom-start',
|
|
14
|
+
offset: 6,
|
|
15
|
+
padding: 8,
|
|
16
|
+
matchWidth: false,
|
|
17
|
+
enabled: true
|
|
18
|
+
};
|
|
19
|
+
function side(placement) {
|
|
20
|
+
return placement.split('-')[0];
|
|
21
|
+
}
|
|
22
|
+
function align(placement) {
|
|
23
|
+
return (placement.split('-')[1] ?? 'center');
|
|
24
|
+
}
|
|
25
|
+
function flip(placement) {
|
|
26
|
+
const opposite = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' };
|
|
27
|
+
const a = align(placement);
|
|
28
|
+
const next = opposite[side(placement)];
|
|
29
|
+
return (a === 'center' ? next : `${next}-${a}`);
|
|
30
|
+
}
|
|
31
|
+
function fits(placement, anchorRect, w, h, gap) {
|
|
32
|
+
switch (side(placement)) {
|
|
33
|
+
case 'top':
|
|
34
|
+
return anchorRect.top - gap - h >= 0;
|
|
35
|
+
case 'bottom':
|
|
36
|
+
return anchorRect.bottom + gap + h <= window.innerHeight;
|
|
37
|
+
case 'left':
|
|
38
|
+
return anchorRect.left - gap - w >= 0;
|
|
39
|
+
case 'right':
|
|
40
|
+
return anchorRect.right + gap + w <= window.innerWidth;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function anchored(node, options = {}) {
|
|
44
|
+
let opts = { ...defaults, ...options };
|
|
45
|
+
const place = () => {
|
|
46
|
+
const anchor = opts.anchor;
|
|
47
|
+
if (!opts.enabled || !anchor?.isConnected)
|
|
48
|
+
return;
|
|
49
|
+
const a = anchor.getBoundingClientRect();
|
|
50
|
+
if (opts.matchWidth)
|
|
51
|
+
node.style.width = `${a.width}px`;
|
|
52
|
+
const { offsetWidth: w, offsetHeight: h } = node;
|
|
53
|
+
const { offset: gap, padding: pad } = opts;
|
|
54
|
+
let placement = opts.placement;
|
|
55
|
+
if (!fits(placement, a, w, h, gap)) {
|
|
56
|
+
const flipped = flip(placement);
|
|
57
|
+
if (fits(flipped, a, w, h, gap))
|
|
58
|
+
placement = flipped;
|
|
59
|
+
}
|
|
60
|
+
let top;
|
|
61
|
+
let left;
|
|
62
|
+
switch (side(placement)) {
|
|
63
|
+
case 'top':
|
|
64
|
+
top = a.top - h - gap;
|
|
65
|
+
break;
|
|
66
|
+
case 'bottom':
|
|
67
|
+
top = a.bottom + gap;
|
|
68
|
+
break;
|
|
69
|
+
default:
|
|
70
|
+
top = a.top + a.height / 2 - h / 2;
|
|
71
|
+
}
|
|
72
|
+
switch (side(placement)) {
|
|
73
|
+
case 'left':
|
|
74
|
+
left = a.left - w - gap;
|
|
75
|
+
break;
|
|
76
|
+
case 'right':
|
|
77
|
+
left = a.right + gap;
|
|
78
|
+
break;
|
|
79
|
+
default:
|
|
80
|
+
left = a.left + a.width / 2 - w / 2;
|
|
81
|
+
}
|
|
82
|
+
// cross-axis alignment for the top/bottom family
|
|
83
|
+
if (side(placement) === 'top' || side(placement) === 'bottom') {
|
|
84
|
+
if (align(placement) === 'start')
|
|
85
|
+
left = a.left;
|
|
86
|
+
else if (align(placement) === 'end')
|
|
87
|
+
left = a.right - w;
|
|
88
|
+
}
|
|
89
|
+
// keep it on screen
|
|
90
|
+
left = Math.min(Math.max(pad, left), Math.max(pad, window.innerWidth - w - pad));
|
|
91
|
+
top = Math.min(Math.max(pad, top), Math.max(pad, window.innerHeight - h - pad));
|
|
92
|
+
node.style.position = 'fixed';
|
|
93
|
+
node.style.top = `${Math.round(top)}px`;
|
|
94
|
+
node.style.left = `${Math.round(left)}px`;
|
|
95
|
+
node.dataset.placement = placement;
|
|
96
|
+
};
|
|
97
|
+
place();
|
|
98
|
+
const observer = new ResizeObserver(place);
|
|
99
|
+
observer.observe(node);
|
|
100
|
+
if (opts.anchor)
|
|
101
|
+
observer.observe(opts.anchor);
|
|
102
|
+
window.addEventListener('scroll', place, true);
|
|
103
|
+
window.addEventListener('resize', place);
|
|
104
|
+
return {
|
|
105
|
+
update(next) {
|
|
106
|
+
const previous = opts.anchor;
|
|
107
|
+
opts = { ...defaults, ...next };
|
|
108
|
+
if (previous !== opts.anchor) {
|
|
109
|
+
if (previous)
|
|
110
|
+
observer.unobserve(previous);
|
|
111
|
+
if (opts.anchor)
|
|
112
|
+
observer.observe(opts.anchor);
|
|
113
|
+
}
|
|
114
|
+
place();
|
|
115
|
+
},
|
|
116
|
+
destroy() {
|
|
117
|
+
observer.disconnect();
|
|
118
|
+
window.removeEventListener('scroll', place, true);
|
|
119
|
+
window.removeEventListener('resize', place);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Behaviour shared by every dismissable overlay: click outside, Escape, focus
|
|
3
|
+
* containment, and returning focus where it came from.
|
|
4
|
+
*
|
|
5
|
+
* They live in one file because overlays always need them together, and the
|
|
6
|
+
* ordering between them matters — Escape must beat click-outside, and focus is
|
|
7
|
+
* only returned once, on teardown.
|
|
8
|
+
*/
|
|
9
|
+
export interface ClickOutsideOptions {
|
|
10
|
+
/** Fires when a pointer goes down outside the node. */
|
|
11
|
+
onoutside?: () => void;
|
|
12
|
+
/** Elements that should not count as "outside" — usually the trigger. */
|
|
13
|
+
ignore?: (HTMLElement | null | undefined)[];
|
|
14
|
+
enabled?: boolean;
|
|
15
|
+
}
|
|
16
|
+
export declare function clickOutside(node: HTMLElement, options?: ClickOutsideOptions): {
|
|
17
|
+
update(next: ClickOutsideOptions): void;
|
|
18
|
+
destroy(): void;
|
|
19
|
+
};
|
|
20
|
+
/** Every focusable descendant, in tab order, skipping hidden ones. */
|
|
21
|
+
export declare function focusable(container: HTMLElement): HTMLElement[];
|
|
22
|
+
export interface FocusTrapOptions {
|
|
23
|
+
enabled?: boolean;
|
|
24
|
+
/** Move focus into the overlay on mount. Default true. */
|
|
25
|
+
autofocus?: boolean;
|
|
26
|
+
/** Restore focus to whatever had it before. Default true. */
|
|
27
|
+
restore?: boolean;
|
|
28
|
+
}
|
|
29
|
+
export declare function focusTrap(node: HTMLElement, options?: FocusTrapOptions): {
|
|
30
|
+
update(next: FocusTrapOptions): void;
|
|
31
|
+
destroy(): void;
|
|
32
|
+
};
|
|
33
|
+
/** Moves the node to `document.body` (or any selector/element) on mount. */
|
|
34
|
+
export declare function portal(node: HTMLElement, target?: HTMLElement | string): {
|
|
35
|
+
update: (to: HTMLElement | string) => void;
|
|
36
|
+
destroy(): void;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Roving focus for menus and listboxes: Arrow keys move, Home/End jump,
|
|
40
|
+
* printable characters type-ahead. Returns whether the key was handled.
|
|
41
|
+
*/
|
|
42
|
+
export declare function navigateList(event: KeyboardEvent, items: HTMLElement[], options?: {
|
|
43
|
+
loop?: boolean;
|
|
44
|
+
}): boolean;
|