@astryxdesign/cli 0.4.2-canary.bb07062 → 0.4.2-canary.da6ea67
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/api/theme/build/build.font-warning.test.mjs +23 -14
- package/api/theme/build/build.icons-specifier.test.mjs +149 -0
- package/api/theme/build/build.mjs +12 -2
- package/api/theme/build/build.test.mjs +10 -13
- package/api/theme/theme.type.d.mts +6 -0
- package/api/theme/theme.type.mjs +4 -1
- package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorDeadlinePicker.doc.mjs +20 -0
- package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorDeadlinePicker.tsx +87 -0
- package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorShowcase.doc.mjs +15 -0
- package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorShowcase.tsx +199 -0
- package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorTreeSearch.doc.mjs +14 -0
- package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorTreeSearch.tsx +188 -0
- package/clients/cli/commands/build-theme.font-warning.test.mjs +9 -6
- package/clients/cli/commands/build-theme.icons-specifier.test.mjs +131 -7
- package/package.json +9 -9
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* API-contract tests for the font-loading
|
|
4
|
+
* API-contract tests for the font-loading advisory in `themeBuild()` (#5015):
|
|
5
5
|
* a theme that names font families it does not load gets one entry per family
|
|
6
|
-
* in the `theme.build` receipt's `
|
|
6
|
+
* in the `theme.build` receipt's `notices` array, on BOTH load paths — a raw
|
|
7
7
|
* typography config (resolved through core's defineTheme) and an
|
|
8
8
|
* already-resolved theme that sets `--font-family-*` tokens directly. Themes
|
|
9
|
-
* that only name generics or known system families
|
|
10
|
-
*
|
|
9
|
+
* that only name generics or known system families say nothing, and the
|
|
10
|
+
* advisory never breaks the API's silence contract (default noopLogger).
|
|
11
|
+
*
|
|
12
|
+
* `notices`, not `warnings`: a theme file cannot load a font — that is the
|
|
13
|
+
* app's job by design — so this fires on correct themes and is context, not a
|
|
14
|
+
* defect to fix. Every assertion here also pins it OUT of `warnings`, since
|
|
15
|
+
* the whole point is that a good theme builds warning-free.
|
|
11
16
|
*
|
|
12
17
|
* Needs a built core — the `node` project's globalSetup
|
|
13
18
|
* (vitest.global-setup.node.mjs) builds it once before workers fork.
|
|
@@ -45,15 +50,16 @@ describe('themeBuild() — font-loading warnings in the receipt', () => {
|
|
|
45
50
|
const result = await themeBuild('fonty.mjs', {}, {cwd: tmpDir});
|
|
46
51
|
|
|
47
52
|
expect(result?.type).toBe('theme.build');
|
|
48
|
-
const
|
|
49
|
-
expect(
|
|
53
|
+
const notices = result?.data.notices ?? [];
|
|
54
|
+
expect(notices).toEqual(
|
|
50
55
|
expect.arrayContaining([
|
|
51
56
|
expect.stringContaining('Font "Space Grotesk"'),
|
|
52
57
|
expect.stringContaining('Font "JetBrains Mono"'),
|
|
53
58
|
]),
|
|
54
59
|
);
|
|
55
|
-
// Heading inherits body's family; the shared family
|
|
56
|
-
expect(
|
|
60
|
+
// Heading inherits body's family; the shared family is named exactly once.
|
|
61
|
+
expect(notices.filter(w => w.includes('Space Grotesk'))).toHaveLength(1);
|
|
62
|
+
expect(result?.data.warnings).toEqual([]);
|
|
57
63
|
});
|
|
58
64
|
|
|
59
65
|
it('warns for an already-resolved theme: font-family tokens and component overrides, nothing else', async () => {
|
|
@@ -74,13 +80,14 @@ describe('themeBuild() — font-loading warnings in the receipt', () => {
|
|
|
74
80
|
// Exactly the two named families — a non-family --font-* token must not
|
|
75
81
|
// produce a bogus "Font \\"1rem\\"" entry, and the components half of the
|
|
76
82
|
// feature must survive the real themeBuild path, not just the unit helper.
|
|
77
|
-
const
|
|
83
|
+
const fontNotices = (result?.data.notices ?? []).filter(w =>
|
|
78
84
|
w.startsWith('Font "'),
|
|
79
85
|
);
|
|
80
|
-
expect(
|
|
86
|
+
expect(fontNotices).toEqual([
|
|
81
87
|
expect.stringContaining('Font "Bungee"'),
|
|
82
88
|
expect.stringContaining('Font "Orbitron"'),
|
|
83
89
|
]);
|
|
90
|
+
expect(result?.data.warnings).toEqual([]);
|
|
84
91
|
});
|
|
85
92
|
|
|
86
93
|
it('warns for a family named only inside a pseudo-class component override (defineTheme path)', async () => {
|
|
@@ -102,13 +109,14 @@ describe('themeBuild() — font-loading warnings in the receipt', () => {
|
|
|
102
109
|
|
|
103
110
|
const result = await themeBuild('pseudo.mjs', {}, {cwd: tmpDir});
|
|
104
111
|
|
|
105
|
-
const
|
|
112
|
+
const fontNotices = (result?.data.notices ?? []).filter(w =>
|
|
106
113
|
w.startsWith('Font "'),
|
|
107
114
|
);
|
|
108
115
|
// Exactly the hidden family — Helvetica/Arial are system fonts.
|
|
109
|
-
expect(
|
|
116
|
+
expect(fontNotices).toEqual([
|
|
110
117
|
expect.stringContaining('Font "Rubik Doodle"'),
|
|
111
118
|
]);
|
|
119
|
+
expect(result?.data.warnings).toEqual([]);
|
|
112
120
|
});
|
|
113
121
|
|
|
114
122
|
it('warns about nothing when every named family is a generic or known system font', async () => {
|
|
@@ -126,10 +134,11 @@ describe('themeBuild() — font-loading warnings in the receipt', () => {
|
|
|
126
134
|
const result = await themeBuild('sys.mjs', {}, {cwd: tmpDir});
|
|
127
135
|
|
|
128
136
|
expect(result?.type).toBe('theme.build');
|
|
137
|
+
expect(result?.data.notices).toEqual([]);
|
|
129
138
|
expect(result?.data.warnings).toEqual([]);
|
|
130
139
|
});
|
|
131
140
|
|
|
132
|
-
it('stays silent under the default noopLogger even when font
|
|
141
|
+
it('stays silent under the default noopLogger even when font notices fire', async () => {
|
|
133
142
|
fs.writeFileSync(
|
|
134
143
|
path.join(tmpDir, 'loud.mjs'),
|
|
135
144
|
`export default { name: 'loud', tokens: { '--font-family-body': '"Orbitron", sans-serif' } };\n`,
|
|
@@ -144,7 +153,7 @@ describe('themeBuild() — font-loading warnings in the receipt', () => {
|
|
|
144
153
|
|
|
145
154
|
try {
|
|
146
155
|
const result = await themeBuild('loud.mjs', {}, {cwd: tmpDir});
|
|
147
|
-
expect(result?.data.
|
|
156
|
+
expect(result?.data.notices).toEqual(
|
|
148
157
|
expect.arrayContaining([expect.stringContaining('Font "Orbitron"')]),
|
|
149
158
|
);
|
|
150
159
|
expect(logSpy).not.toHaveBeenCalled();
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Direct-API tests for `themeBuild`'s `iconsSpecifier` option (#4620).
|
|
5
|
+
*
|
|
6
|
+
* The CLI surface of `--icons-specifier` is pinned in
|
|
7
|
+
* clients/cli/commands/build-theme.icons-specifier.test.mjs; these tests pin
|
|
8
|
+
* the programmatic surface that watch mode, editor tooling, and build scripts
|
|
9
|
+
* call directly: the option reaches the emitted module, its absence leaves the
|
|
10
|
+
* scraped specifier byte-for-byte alone, and `check` mode compares the
|
|
11
|
+
* specifier-bearing text like any other generated byte — outputs built with a
|
|
12
|
+
* different specifier than the one being checked against are stale, not clean.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {describe, it, expect, beforeEach, afterEach} from 'vitest';
|
|
16
|
+
import * as fs from 'node:fs';
|
|
17
|
+
import * as path from 'node:path';
|
|
18
|
+
import * as os from 'node:os';
|
|
19
|
+
import {themeBuild} from './build.mjs';
|
|
20
|
+
|
|
21
|
+
let tmpDir;
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
tmpDir = fs.mkdtempSync(
|
|
24
|
+
path.join(os.tmpdir(), 'astryx-api-icons-specifier-'),
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
fs.rmSync(tmpDir, {recursive: true, force: true});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Write a theme source (plus a loadable icons source beside it) under
|
|
33
|
+
* `<tmpDir>/src/`. The registry is a plain object — the emit path only
|
|
34
|
+
* re-exports it, so no React is needed.
|
|
35
|
+
*/
|
|
36
|
+
function writeIconTheme({withIcons = true, name = 'icotheme'} = {}) {
|
|
37
|
+
const srcDir = path.join(tmpDir, 'src');
|
|
38
|
+
fs.mkdirSync(srcDir, {recursive: true});
|
|
39
|
+
fs.writeFileSync(
|
|
40
|
+
path.join(srcDir, 'icons.ts'),
|
|
41
|
+
`export const myIcons = { close: 'x' };\n`,
|
|
42
|
+
);
|
|
43
|
+
const iconLines = withIcons ? [`import { myIcons } from './icons';`] : [];
|
|
44
|
+
fs.writeFileSync(
|
|
45
|
+
path.join(srcDir, `${name}.ts`),
|
|
46
|
+
[
|
|
47
|
+
...iconLines,
|
|
48
|
+
`export default { name: '${name}', tokens: { '--color-bg': '#fff' }${
|
|
49
|
+
withIcons ? ', icons: myIcons' : ''
|
|
50
|
+
} };`,
|
|
51
|
+
'',
|
|
52
|
+
].join('\n'),
|
|
53
|
+
);
|
|
54
|
+
return `src/${name}.ts`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function builtModule(name = 'icotheme') {
|
|
58
|
+
return fs.readFileSync(path.join(tmpDir, 'dist', `${name}.js`), 'utf8');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe('themeBuild({iconsSpecifier}) — direct API', () => {
|
|
62
|
+
it('emits the declared specifier into the generated module', async () => {
|
|
63
|
+
const file = writeIconTheme();
|
|
64
|
+
|
|
65
|
+
const result = await themeBuild(
|
|
66
|
+
file,
|
|
67
|
+
{out: 'dist/theme.css', iconsSpecifier: './icons.mjs'},
|
|
68
|
+
{cwd: tmpDir},
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
expect(result?.type).toBe('theme.build');
|
|
72
|
+
const js = builtModule();
|
|
73
|
+
expect(js).toContain(`import { myIcons } from "./icons.mjs";`);
|
|
74
|
+
// The registry re-export survives the override.
|
|
75
|
+
expect(js).toContain('export { myIcons }');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('emits the scraped specifier unchanged when the option is omitted', async () => {
|
|
79
|
+
const file = writeIconTheme();
|
|
80
|
+
|
|
81
|
+
const result = await themeBuild(
|
|
82
|
+
file,
|
|
83
|
+
{out: 'dist/theme.css'},
|
|
84
|
+
{cwd: tmpDir},
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
expect(result?.type).toBe('theme.build');
|
|
88
|
+
expect(builtModule()).toContain(`import { myIcons } from './icons';`);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('is inert for a theme with no icons field', async () => {
|
|
92
|
+
const file = writeIconTheme({withIcons: false});
|
|
93
|
+
|
|
94
|
+
const result = await themeBuild(
|
|
95
|
+
file,
|
|
96
|
+
{out: 'dist/theme.css', iconsSpecifier: './icons.mjs'},
|
|
97
|
+
{cwd: tmpDir},
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
expect(result?.type).toBe('theme.build');
|
|
101
|
+
// The header comment's usage example mentions imports; only a real
|
|
102
|
+
// statement (line-leading `import`) would be a leak.
|
|
103
|
+
const js = builtModule();
|
|
104
|
+
expect(js).not.toMatch(/^import /m);
|
|
105
|
+
expect(js).not.toContain('icons.mjs');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('check mode is clean against outputs built with the same specifier', async () => {
|
|
109
|
+
const file = writeIconTheme();
|
|
110
|
+
await themeBuild(
|
|
111
|
+
file,
|
|
112
|
+
{out: 'dist/theme.css', iconsSpecifier: './icons.mjs'},
|
|
113
|
+
{cwd: tmpDir},
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const result = await themeBuild(
|
|
117
|
+
file,
|
|
118
|
+
{out: 'dist/theme.css', check: true, iconsSpecifier: './icons.mjs'},
|
|
119
|
+
{cwd: tmpDir},
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
expect(result?.type).toBe('theme.build.check');
|
|
123
|
+
expect(result?.data.upToDate).toBe(true);
|
|
124
|
+
expect(result?.data.stale).toEqual([]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('check mode reports outputs stale when the specifier differs', async () => {
|
|
128
|
+
const file = writeIconTheme();
|
|
129
|
+
await themeBuild(
|
|
130
|
+
file,
|
|
131
|
+
{out: 'dist/theme.css', iconsSpecifier: './icons.mjs'},
|
|
132
|
+
{cwd: tmpDir},
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
// Checking without the option regenerates with the scraped './icons' —
|
|
136
|
+
// different bytes than the on-disk module, so the check must flag it.
|
|
137
|
+
const result = await themeBuild(
|
|
138
|
+
file,
|
|
139
|
+
{out: 'dist/theme.css', check: true},
|
|
140
|
+
{cwd: tmpDir},
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
expect(result?.type).toBe('theme.build.check');
|
|
144
|
+
expect(result?.data.upToDate).toBe(false);
|
|
145
|
+
expect(result?.data.stale.some(entry => entry.reason === 'outdated')).toBe(
|
|
146
|
+
true,
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -1114,6 +1114,8 @@ export async function themeBuild(
|
|
|
1114
1114
|
// Validate component overrides
|
|
1115
1115
|
const warnings = await validateComponentOverrides(themeDef);
|
|
1116
1116
|
const warningMessages = [];
|
|
1117
|
+
/** Advisories about a correct theme — see the `notices` note on the receipt. */
|
|
1118
|
+
const noticeMessages = [];
|
|
1117
1119
|
for (const w of warnings) {
|
|
1118
1120
|
warningMessages.push(w);
|
|
1119
1121
|
logger.warn(` ⚠ ${w}`);
|
|
@@ -1412,11 +1414,18 @@ Or with a <link> tag:
|
|
|
1412
1414
|
// Fonts the theme names but nothing loads (#5015). Resolved tokens and
|
|
1413
1415
|
// component overrides carry the final font-family values on both load
|
|
1414
1416
|
// paths, so this sees jiti-resolved and legacy themes alike.
|
|
1417
|
+
//
|
|
1418
|
+
// A NOTICE, not a warning: naming a font a theme file cannot load is how
|
|
1419
|
+
// the API is meant to be used — Astryx sets `--font-family-*` and loading
|
|
1420
|
+
// is the app's job, which no theme can do for it. So this fires on any
|
|
1421
|
+
// theme with a webfont, including a perfect one, and as a warning it made
|
|
1422
|
+
// every such build read as defective (it also put the shipped template
|
|
1423
|
+
// permanently in violation of its own "compiles with no warnings" guard).
|
|
1415
1424
|
const unloadedFonts = collectUnloadedFonts(resolvedTheme);
|
|
1416
1425
|
for (const family of unloadedFonts) {
|
|
1417
1426
|
const msg = `Font "${family}" is named by this theme but not loaded — add a <link> or @font-face in your app (recipe: astryx docs typography)`;
|
|
1418
|
-
|
|
1419
|
-
logger.
|
|
1427
|
+
noticeMessages.push(msg);
|
|
1428
|
+
logger.log(` note: ${msg}`);
|
|
1420
1429
|
}
|
|
1421
1430
|
if (unloadedFonts.length > 0) {
|
|
1422
1431
|
logger.log(formatFontLoadingHelp(themeDef.name, unloadedFonts));
|
|
@@ -1438,6 +1447,7 @@ Or with a <link> tag:
|
|
|
1438
1447
|
: {}),
|
|
1439
1448
|
},
|
|
1440
1449
|
warnings: warningMessages,
|
|
1450
|
+
notices: noticeMessages,
|
|
1441
1451
|
},
|
|
1442
1452
|
};
|
|
1443
1453
|
}
|
|
@@ -299,11 +299,10 @@ describe('themeBuild() — component override validation', () => {
|
|
|
299
299
|
describe('themeBuild() — the shipped theme template', () => {
|
|
300
300
|
// `assets/theme.template.ts` is what `astryx theme template` puts in a
|
|
301
301
|
// consumer's project. It is the one theme file we hand out, so it has to
|
|
302
|
-
// compile as shipped — and cleanly
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
|
|
306
|
-
it('compiles as shipped, warning only about the fonts it deliberately names', async () => {
|
|
302
|
+
// compile as shipped — and cleanly: a template that greets its first reader
|
|
303
|
+
// with warnings teaches them to ignore warnings. The claims its comments make
|
|
304
|
+
// are checked separately by scripts/check-theme-template.test.mjs.
|
|
305
|
+
it('compiles as shipped, with no warnings', async () => {
|
|
307
306
|
const src = path.resolve(
|
|
308
307
|
import.meta.dirname,
|
|
309
308
|
'../../../assets/theme.template.ts',
|
|
@@ -312,14 +311,12 @@ describe('themeBuild() — the shipped theme template', () => {
|
|
|
312
311
|
|
|
313
312
|
const result = await themeBuild('theme.template.ts', {}, {cwd: tmpDir});
|
|
314
313
|
|
|
315
|
-
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
);
|
|
321
|
-
expect(unexpected).toEqual([]);
|
|
322
|
-
expect(result?.data.warnings).toHaveLength(2);
|
|
314
|
+
expect(result?.data.warnings).toEqual([]);
|
|
315
|
+
// It DOES name Inter and Geist Mono without loading them, to teach "SHIP
|
|
316
|
+
// THE FONTS YOU NAME" — advisories about a correct file, which is why they
|
|
317
|
+
// are notices. Asserted here so moving them out of `warnings` cannot
|
|
318
|
+
// quietly become dropping them.
|
|
319
|
+
expect(result?.data.notices).toHaveLength(2);
|
|
323
320
|
expect(fs.existsSync(path.join(tmpDir, 'my-theme.css'))).toBe(true);
|
|
324
321
|
// The template teaches custom variants; the augmentation it promises the
|
|
325
322
|
// reader has to actually be generated.
|
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
* xds --json theme build <file>
|
|
6
6
|
*/
|
|
7
7
|
export type ThemeBuildResponse = {
|
|
8
|
+
/**
|
|
9
|
+
* `warnings` are defects the theme author should fix. `notices` are advisories
|
|
10
|
+
* about a correct theme — most of them cannot be fixed in a theme file at all,
|
|
11
|
+
* so folding them into `warnings` makes a clean build look dirty.
|
|
12
|
+
*/
|
|
8
13
|
type: "theme.build";
|
|
9
14
|
data: {
|
|
10
15
|
name: string;
|
|
@@ -18,6 +23,7 @@ export type ThemeBuildResponse = {
|
|
|
18
23
|
variantsDts?: string;
|
|
19
24
|
};
|
|
20
25
|
warnings: string[];
|
|
26
|
+
notices: string[];
|
|
21
27
|
};
|
|
22
28
|
};
|
|
23
29
|
/**
|
package/api/theme/theme.type.mjs
CHANGED
|
@@ -22,7 +22,10 @@
|
|
|
22
22
|
* xds --json theme build <file>
|
|
23
23
|
* @typedef {object} ThemeBuildResponse
|
|
24
24
|
* @property {'theme.build'} type
|
|
25
|
-
*
|
|
25
|
+
* `warnings` are defects the theme author should fix. `notices` are advisories
|
|
26
|
+
* about a correct theme — most of them cannot be fixed in a theme file at all,
|
|
27
|
+
* so folding them into `warnings` makes a clean build look dirty.
|
|
28
|
+
* @property {{name: string, tokenCount: number, componentCount: number, sizeKB: number, outputs: {css: string, js: string, dts: string, variantsDts?: string}, warnings: string[], notices: string[]}} data
|
|
26
29
|
*/
|
|
27
30
|
|
|
28
31
|
/**
|
package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorDeadlinePicker.doc.mjs
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */
|
|
4
|
+
export const doc = {
|
|
5
|
+
type: 'block',
|
|
6
|
+
exampleFor: 'ComplexSelector',
|
|
7
|
+
name: 'ComplexSelector — Deadline Picker',
|
|
8
|
+
displayName: 'Complex Selector — Deadline Picker',
|
|
9
|
+
description:
|
|
10
|
+
'A multi-step deadline field: pick a preset like Today or Next week, or switch to a custom date and time before applying. The popup stays open until the user commits, so the content owns the Apply action.',
|
|
11
|
+
isReady: true,
|
|
12
|
+
aspectRatio: 4 / 3,
|
|
13
|
+
componentsUsed: [
|
|
14
|
+
'ComplexSelector',
|
|
15
|
+
'RadioList',
|
|
16
|
+
'DateInput',
|
|
17
|
+
'TimeInput',
|
|
18
|
+
'Button',
|
|
19
|
+
],
|
|
20
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
import {useState} from 'react';
|
|
6
|
+
import {ComplexSelector} from '@astryxdesign/core/ComplexSelector';
|
|
7
|
+
import {RadioList, RadioListItem} from '@astryxdesign/core/RadioList';
|
|
8
|
+
import {DateInput} from '@astryxdesign/core/DateInput';
|
|
9
|
+
import {TimeInput} from '@astryxdesign/core/TimeInput';
|
|
10
|
+
import {Button} from '@astryxdesign/core/Button';
|
|
11
|
+
import {VStack} from '@astryxdesign/core/Layout';
|
|
12
|
+
|
|
13
|
+
type ISODate =
|
|
14
|
+
`${number}${number}${number}${number}-${number}${number}-${number}${number}`;
|
|
15
|
+
type ISOTime = string & {readonly __brand: 'ISOTimeString'};
|
|
16
|
+
|
|
17
|
+
interface Deadline {
|
|
18
|
+
preset: 'today' | 'next-week' | 'custom';
|
|
19
|
+
date: ISODate;
|
|
20
|
+
time: ISOTime;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const presetLabels: Record<Deadline['preset'], string> = {
|
|
24
|
+
today: 'Today',
|
|
25
|
+
'next-week': 'Next week',
|
|
26
|
+
custom: 'Custom date',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function formatDeadline(value: Deadline) {
|
|
30
|
+
if (value.preset === 'custom') {
|
|
31
|
+
return `${value.date} at ${value.time}`;
|
|
32
|
+
}
|
|
33
|
+
return presetLabels[value.preset];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default function ComplexSelectorDeadlinePicker() {
|
|
37
|
+
const [value, setValue] = useState<Deadline>({
|
|
38
|
+
preset: 'today',
|
|
39
|
+
date: '2026-04-06' as ISODate,
|
|
40
|
+
time: '17:00' as ISOTime,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<ComplexSelector<Deadline>
|
|
45
|
+
label="Deadline"
|
|
46
|
+
description="Choose a preset or set a custom date and time."
|
|
47
|
+
value={value}
|
|
48
|
+
onChange={setValue}
|
|
49
|
+
triggerLabel={formatDeadline(value)}
|
|
50
|
+
style={{width: 320}}>
|
|
51
|
+
{(selectedValue, onChange, close) => {
|
|
52
|
+
const set = (patch: Partial<Deadline>) =>
|
|
53
|
+
onChange({...selectedValue, ...patch});
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<VStack gap={4} style={{width: 320}}>
|
|
57
|
+
<RadioList
|
|
58
|
+
label="When is it due?"
|
|
59
|
+
value={selectedValue.preset}
|
|
60
|
+
onChange={preset => set({preset: preset as Deadline['preset']})}>
|
|
61
|
+
<RadioListItem label="Today" value="today" />
|
|
62
|
+
<RadioListItem label="Next week" value="next-week" />
|
|
63
|
+
<RadioListItem label="Custom date" value="custom" />
|
|
64
|
+
</RadioList>
|
|
65
|
+
|
|
66
|
+
{selectedValue.preset === 'custom' && (
|
|
67
|
+
<VStack gap={3}>
|
|
68
|
+
<DateInput
|
|
69
|
+
label="Date"
|
|
70
|
+
value={selectedValue.date}
|
|
71
|
+
onChange={date => date && set({date})}
|
|
72
|
+
/>
|
|
73
|
+
<TimeInput
|
|
74
|
+
label="Time"
|
|
75
|
+
value={selectedValue.time}
|
|
76
|
+
onChange={time => time && set({time})}
|
|
77
|
+
/>
|
|
78
|
+
</VStack>
|
|
79
|
+
)}
|
|
80
|
+
|
|
81
|
+
<Button label="Apply" variant="primary" onClick={close} />
|
|
82
|
+
</VStack>
|
|
83
|
+
);
|
|
84
|
+
}}
|
|
85
|
+
</ComplexSelector>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */
|
|
4
|
+
export const doc = {
|
|
5
|
+
type: 'block',
|
|
6
|
+
exampleFor: 'ComplexSelector',
|
|
7
|
+
name: 'ComplexSelector',
|
|
8
|
+
displayName: 'Complex Selector',
|
|
9
|
+
description:
|
|
10
|
+
'A two-axis picker: choose a fruit and a ripeness level from one control. ComplexSelector owns the trigger, popover, and focus restore while the custom grid owns its keyboard semantics.',
|
|
11
|
+
isReady: true,
|
|
12
|
+
aspectRatio: 1,
|
|
13
|
+
isShowcase: true,
|
|
14
|
+
componentsUsed: ['ComplexSelector', 'Text'],
|
|
15
|
+
};
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
import {useEffect, useState} from 'react';
|
|
6
|
+
import * as stylex from '@stylexjs/stylex';
|
|
7
|
+
import {ComplexSelector} from '@astryxdesign/core/ComplexSelector';
|
|
8
|
+
import {Text} from '@astryxdesign/core/Text';
|
|
9
|
+
import {useGridFocus} from '@astryxdesign/core/hooks';
|
|
10
|
+
import {
|
|
11
|
+
borderVars,
|
|
12
|
+
colorVars,
|
|
13
|
+
radiusVars,
|
|
14
|
+
spacingVars,
|
|
15
|
+
} from '@astryxdesign/core/theme/tokens.stylex';
|
|
16
|
+
|
|
17
|
+
type Fruit = 'Apple' | 'Pear' | 'Peach' | 'Plum';
|
|
18
|
+
type Ripeness = 'Crisp' | 'Tender' | 'Juicy' | 'Peak';
|
|
19
|
+
|
|
20
|
+
interface FruitValue {
|
|
21
|
+
fruit: Fruit;
|
|
22
|
+
ripeness: Ripeness;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const fruits: Array<{id: Fruit; emoji: string; description: string}> = [
|
|
26
|
+
{id: 'Apple', emoji: '🍎', description: 'Bright and balanced'},
|
|
27
|
+
{id: 'Pear', emoji: '🍐', description: 'Soft floral sweetness'},
|
|
28
|
+
{id: 'Peach', emoji: '🍑', description: 'Round summer flavor'},
|
|
29
|
+
{id: 'Plum', emoji: '🟣', description: 'Jammy and tart'},
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const ripenessLevels: Array<{
|
|
33
|
+
id: Ripeness;
|
|
34
|
+
shortLabel: string;
|
|
35
|
+
description: string;
|
|
36
|
+
}> = [
|
|
37
|
+
{id: 'Crisp', shortLabel: 'C', description: 'Snappy bite'},
|
|
38
|
+
{id: 'Tender', shortLabel: 'T', description: 'Easy bite'},
|
|
39
|
+
{id: 'Juicy', shortLabel: 'J', description: 'Full juice'},
|
|
40
|
+
{id: 'Peak', shortLabel: 'P', description: 'Most intense'},
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const GRID_CELL_SELECTOR = '[role="gridcell"]';
|
|
44
|
+
|
|
45
|
+
const styles = stylex.create({
|
|
46
|
+
grid: {
|
|
47
|
+
display: 'flex',
|
|
48
|
+
flexDirection: 'column',
|
|
49
|
+
gap: spacingVars['--spacing-1'],
|
|
50
|
+
minWidth: 280,
|
|
51
|
+
},
|
|
52
|
+
row: {
|
|
53
|
+
display: 'grid',
|
|
54
|
+
gridTemplateColumns: 'minmax(150px, 1fr) repeat(4, 44px)',
|
|
55
|
+
alignItems: 'center',
|
|
56
|
+
columnGap: spacingVars['--spacing-1'],
|
|
57
|
+
},
|
|
58
|
+
rowHeader: {
|
|
59
|
+
display: 'flex',
|
|
60
|
+
alignItems: 'center',
|
|
61
|
+
gap: spacingVars['--spacing-2'],
|
|
62
|
+
textAlign: 'start',
|
|
63
|
+
minWidth: 0,
|
|
64
|
+
},
|
|
65
|
+
emoji: {
|
|
66
|
+
fontSize: 18,
|
|
67
|
+
flexShrink: 0,
|
|
68
|
+
},
|
|
69
|
+
fruitText: {
|
|
70
|
+
display: 'flex',
|
|
71
|
+
flexDirection: 'column',
|
|
72
|
+
minWidth: 0,
|
|
73
|
+
},
|
|
74
|
+
cell: {
|
|
75
|
+
display: 'flex',
|
|
76
|
+
alignItems: 'center',
|
|
77
|
+
justifyContent: 'center',
|
|
78
|
+
height: 36,
|
|
79
|
+
borderWidth: borderVars['--border-width'],
|
|
80
|
+
borderStyle: 'solid',
|
|
81
|
+
borderColor: colorVars['--color-border'],
|
|
82
|
+
borderRadius: radiusVars['--radius-container'],
|
|
83
|
+
backgroundColor: colorVars['--color-background-card'],
|
|
84
|
+
color: colorVars['--color-text-secondary'],
|
|
85
|
+
fontFamily: 'inherit',
|
|
86
|
+
cursor: 'pointer',
|
|
87
|
+
':hover': {
|
|
88
|
+
'@media (hover: hover)': {
|
|
89
|
+
borderColor: colorVars['--color-border-emphasized'],
|
|
90
|
+
color: colorVars['--color-text-primary'],
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
cellSelected: {
|
|
95
|
+
borderColor: colorVars['--color-accent'],
|
|
96
|
+
backgroundColor: colorVars['--color-accent'],
|
|
97
|
+
color: colorVars['--color-on-accent'],
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
function FruitRipenessGrid({
|
|
102
|
+
value,
|
|
103
|
+
onChange,
|
|
104
|
+
}: {
|
|
105
|
+
value: FruitValue;
|
|
106
|
+
onChange: (value: FruitValue) => void;
|
|
107
|
+
}) {
|
|
108
|
+
const {gridRef, handleKeyDown, handleFocus, focusCell} =
|
|
109
|
+
useGridFocus<HTMLDivElement>({
|
|
110
|
+
columns: ripenessLevels.length,
|
|
111
|
+
cellSelector: GRID_CELL_SELECTOR,
|
|
112
|
+
hasRovingTabIndex: true,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
const rowIndex = fruits.findIndex(f => f.id === value.fruit);
|
|
117
|
+
const columnIndex = ripenessLevels.findIndex(l => l.id === value.ripeness);
|
|
118
|
+
requestAnimationFrame(() => {
|
|
119
|
+
focusCell(
|
|
120
|
+
rowIndex >= 0 && columnIndex >= 0
|
|
121
|
+
? rowIndex * ripenessLevels.length + columnIndex
|
|
122
|
+
: 0,
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
}, [focusCell, value]);
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<div
|
|
129
|
+
ref={gridRef}
|
|
130
|
+
role="grid"
|
|
131
|
+
aria-label="Fruit ripeness choices"
|
|
132
|
+
onKeyDown={handleKeyDown}
|
|
133
|
+
onFocus={handleFocus}
|
|
134
|
+
{...stylex.props(styles.grid)}>
|
|
135
|
+
{fruits.map(fruit => (
|
|
136
|
+
<div key={fruit.id} role="row" {...stylex.props(styles.row)}>
|
|
137
|
+
<div role="rowheader" {...stylex.props(styles.rowHeader)}>
|
|
138
|
+
<span aria-hidden="true" {...stylex.props(styles.emoji)}>
|
|
139
|
+
{fruit.emoji}
|
|
140
|
+
</span>
|
|
141
|
+
<span {...stylex.props(styles.fruitText)}>
|
|
142
|
+
<Text type="body">{fruit.id}</Text>
|
|
143
|
+
<Text type="supporting" color="secondary">
|
|
144
|
+
{fruit.description}
|
|
145
|
+
</Text>
|
|
146
|
+
</span>
|
|
147
|
+
</div>
|
|
148
|
+
{ripenessLevels.map(level => {
|
|
149
|
+
const isSelected =
|
|
150
|
+
value.fruit === fruit.id && value.ripeness === level.id;
|
|
151
|
+
return (
|
|
152
|
+
<button
|
|
153
|
+
key={`${fruit.id}-${level.id}`}
|
|
154
|
+
type="button"
|
|
155
|
+
role="gridcell"
|
|
156
|
+
aria-label={`${fruit.id}, ${level.id}: ${level.description}`}
|
|
157
|
+
aria-selected={isSelected || undefined}
|
|
158
|
+
tabIndex={isSelected ? 0 : -1}
|
|
159
|
+
onClick={() => onChange({fruit: fruit.id, ripeness: level.id})}
|
|
160
|
+
{...stylex.props(
|
|
161
|
+
styles.cell,
|
|
162
|
+
isSelected && styles.cellSelected,
|
|
163
|
+
)}>
|
|
164
|
+
{level.shortLabel}
|
|
165
|
+
</button>
|
|
166
|
+
);
|
|
167
|
+
})}
|
|
168
|
+
</div>
|
|
169
|
+
))}
|
|
170
|
+
</div>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export default function ComplexSelectorShowcase() {
|
|
175
|
+
const [value, setValue] = useState<FruitValue>({
|
|
176
|
+
fruit: 'Apple',
|
|
177
|
+
ripeness: 'Juicy',
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<ComplexSelector<FruitValue>
|
|
182
|
+
label="Fruit blend"
|
|
183
|
+
description="Choose a fruit and ripeness in one control. Arrow keys move across the grid."
|
|
184
|
+
value={value}
|
|
185
|
+
onChange={setValue}
|
|
186
|
+
triggerLabel={`${value.fruit} · ${value.ripeness}`}
|
|
187
|
+
style={{width: 280}}>
|
|
188
|
+
{(selectedValue, onChange, close) => (
|
|
189
|
+
<FruitRipenessGrid
|
|
190
|
+
value={selectedValue}
|
|
191
|
+
onChange={nextValue => {
|
|
192
|
+
onChange(nextValue);
|
|
193
|
+
close();
|
|
194
|
+
}}
|
|
195
|
+
/>
|
|
196
|
+
)}
|
|
197
|
+
</ComplexSelector>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */
|
|
4
|
+
export const doc = {
|
|
5
|
+
type: 'block',
|
|
6
|
+
exampleFor: 'ComplexSelector',
|
|
7
|
+
name: 'ComplexSelector — Tree Search',
|
|
8
|
+
displayName: 'Complex Selector — Tree Search',
|
|
9
|
+
description:
|
|
10
|
+
'A destination picker that combines a search field with a TreeList hierarchy. TreeList owns tree keyboard navigation; ComplexSelector owns the trigger, popover, and focus restore. Selecting a folder closes the popup.',
|
|
11
|
+
isReady: true,
|
|
12
|
+
aspectRatio: 4 / 3,
|
|
13
|
+
componentsUsed: ['ComplexSelector', 'TextInput', 'TreeList'],
|
|
14
|
+
};
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
import {useMemo, useState} from 'react';
|
|
6
|
+
import {ComplexSelector} from '@astryxdesign/core/ComplexSelector';
|
|
7
|
+
import {TextInput} from '@astryxdesign/core/TextInput';
|
|
8
|
+
import {TreeList, type TreeListItemData} from '@astryxdesign/core/TreeList';
|
|
9
|
+
import {Text} from '@astryxdesign/core/Text';
|
|
10
|
+
import {VStack} from '@astryxdesign/core/Layout';
|
|
11
|
+
|
|
12
|
+
interface DestinationNode {
|
|
13
|
+
id: string;
|
|
14
|
+
label: string;
|
|
15
|
+
path: string;
|
|
16
|
+
children?: DestinationNode[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface Destination {
|
|
20
|
+
id: string;
|
|
21
|
+
label: string;
|
|
22
|
+
path: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const destinationTree: DestinationNode[] = [
|
|
26
|
+
{
|
|
27
|
+
id: 'workspace',
|
|
28
|
+
label: 'Workspace',
|
|
29
|
+
path: '/Workspace',
|
|
30
|
+
children: [
|
|
31
|
+
{
|
|
32
|
+
id: 'research',
|
|
33
|
+
label: 'Research',
|
|
34
|
+
path: '/Workspace/Research',
|
|
35
|
+
children: [
|
|
36
|
+
{
|
|
37
|
+
id: 'field-notes',
|
|
38
|
+
label: 'Field notes',
|
|
39
|
+
path: '/Workspace/Research/Field notes',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: 'interviews',
|
|
43
|
+
label: 'Interviews',
|
|
44
|
+
path: '/Workspace/Research/Interviews',
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: 'roadmap',
|
|
50
|
+
label: 'Roadmap',
|
|
51
|
+
path: '/Workspace/Roadmap',
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: 'teams',
|
|
57
|
+
label: 'Teams',
|
|
58
|
+
path: '/Teams',
|
|
59
|
+
children: [
|
|
60
|
+
{
|
|
61
|
+
id: 'design-systems',
|
|
62
|
+
label: 'Design systems',
|
|
63
|
+
path: '/Teams/Design systems',
|
|
64
|
+
children: [
|
|
65
|
+
{
|
|
66
|
+
id: 'accessibility',
|
|
67
|
+
label: 'Accessibility',
|
|
68
|
+
path: '/Teams/Design systems/Accessibility',
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
function matches(node: DestinationNode, query: string): boolean {
|
|
77
|
+
if (node.label.toLowerCase().includes(query)) {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
return (node.children ?? []).some(child => matches(child, query));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function filterTree(
|
|
84
|
+
nodes: DestinationNode[],
|
|
85
|
+
query: string,
|
|
86
|
+
): DestinationNode[] {
|
|
87
|
+
if (!query) {
|
|
88
|
+
return nodes;
|
|
89
|
+
}
|
|
90
|
+
return nodes
|
|
91
|
+
.filter(node => matches(node, query))
|
|
92
|
+
.map(node => ({
|
|
93
|
+
...node,
|
|
94
|
+
children: node.children ? filterTree(node.children, query) : undefined,
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function toItems(
|
|
99
|
+
nodes: DestinationNode[],
|
|
100
|
+
selectedId: string,
|
|
101
|
+
onSelect: (value: Destination) => void,
|
|
102
|
+
): TreeListItemData[] {
|
|
103
|
+
return nodes.map(node => {
|
|
104
|
+
const hasChildren = (node.children ?? []).length > 0;
|
|
105
|
+
return {
|
|
106
|
+
id: node.id,
|
|
107
|
+
label: node.label,
|
|
108
|
+
isSelected: node.id === selectedId,
|
|
109
|
+
isExpanded: true,
|
|
110
|
+
onClick: hasChildren
|
|
111
|
+
? undefined
|
|
112
|
+
: () => onSelect({id: node.id, label: node.label, path: node.path}),
|
|
113
|
+
children: hasChildren
|
|
114
|
+
? toItems(node.children ?? [], selectedId, onSelect)
|
|
115
|
+
: undefined,
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function DestinationSearch({
|
|
121
|
+
value,
|
|
122
|
+
onChange,
|
|
123
|
+
close,
|
|
124
|
+
}: {
|
|
125
|
+
value: Destination;
|
|
126
|
+
onChange: (value: Destination) => void;
|
|
127
|
+
close: () => void;
|
|
128
|
+
}) {
|
|
129
|
+
const [query, setQuery] = useState('');
|
|
130
|
+
const items = useMemo(
|
|
131
|
+
() =>
|
|
132
|
+
toItems(
|
|
133
|
+
filterTree(destinationTree, query.toLowerCase()),
|
|
134
|
+
value.id,
|
|
135
|
+
next => {
|
|
136
|
+
onChange(next);
|
|
137
|
+
close();
|
|
138
|
+
},
|
|
139
|
+
),
|
|
140
|
+
[query, value.id, onChange, close],
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<VStack gap={3} style={{width: 360}}>
|
|
145
|
+
<TextInput
|
|
146
|
+
label="Search destinations"
|
|
147
|
+
isLabelHidden
|
|
148
|
+
value={query}
|
|
149
|
+
onChange={setQuery}
|
|
150
|
+
hasClear
|
|
151
|
+
placeholder="Search folders or teams"
|
|
152
|
+
/>
|
|
153
|
+
{items.length > 0 ? (
|
|
154
|
+
<TreeList items={items} density="compact" />
|
|
155
|
+
) : (
|
|
156
|
+
<Text type="supporting" color="secondary">
|
|
157
|
+
No matching destinations.
|
|
158
|
+
</Text>
|
|
159
|
+
)}
|
|
160
|
+
</VStack>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export default function ComplexSelectorTreeSearch() {
|
|
165
|
+
const [value, setValue] = useState<Destination>({
|
|
166
|
+
id: 'accessibility',
|
|
167
|
+
label: 'Accessibility',
|
|
168
|
+
path: '/Teams/Design systems/Accessibility',
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
<ComplexSelector<Destination>
|
|
173
|
+
label="Destination"
|
|
174
|
+
description="Search and browse nested folders."
|
|
175
|
+
value={value}
|
|
176
|
+
onChange={setValue}
|
|
177
|
+
triggerLabel={value.path}
|
|
178
|
+
style={{width: 360}}>
|
|
179
|
+
{(selectedValue, onChange, close) => (
|
|
180
|
+
<DestinationSearch
|
|
181
|
+
value={selectedValue}
|
|
182
|
+
onChange={onChange}
|
|
183
|
+
close={close}
|
|
184
|
+
/>
|
|
185
|
+
)}
|
|
186
|
+
</ComplexSelector>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
@@ -75,13 +75,16 @@ describe('theme build font-loading warning', () => {
|
|
|
75
75
|
expect(result.stdout).toContain('@font-face');
|
|
76
76
|
expect(result.stdout).toContain('font-display: swap');
|
|
77
77
|
expect(result.stdout).toContain('astryx docs typography');
|
|
78
|
-
// The one-line summaries follow the CLI's stream contract
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
|
|
78
|
+
// The one-line summaries follow the CLI's stream contract. These are
|
|
79
|
+
// NOTICES about a correct theme, not warnings, so they go to stdout with
|
|
80
|
+
// the rest of the build's progress — stderr stays for the defects an
|
|
81
|
+
// author has to fix.
|
|
82
|
+
expect(result.stdout).toContain('note: Font "Space Grotesk"');
|
|
83
|
+
expect(result.stdout).toContain('note: Font "JetBrains Mono"');
|
|
84
|
+
expect(result.stderr).not.toContain('Font "');
|
|
82
85
|
});
|
|
83
86
|
|
|
84
|
-
it('keeps --json stdout one valid envelope:
|
|
87
|
+
it('keeps --json stdout one valid envelope: notices inside, snippet suppressed', async () => {
|
|
85
88
|
const project = path.join(tmpDir, 'project');
|
|
86
89
|
const themeFile = writeTheme(
|
|
87
90
|
project,
|
|
@@ -100,7 +103,7 @@ describe('theme build font-loading warning', () => {
|
|
|
100
103
|
// contract, not just substring presence.
|
|
101
104
|
const envelope = JSON.parse(result.stdout);
|
|
102
105
|
expect(envelope.type).toBe('theme.build');
|
|
103
|
-
expect(envelope.data.
|
|
106
|
+
expect(envelope.data.notices).toEqual(
|
|
104
107
|
expect.arrayContaining([expect.stringContaining('Font "Space Grotesk"')]),
|
|
105
108
|
);
|
|
106
109
|
expect(result.stdout).not.toContain('fonts.googleapis.com');
|
|
@@ -15,15 +15,34 @@
|
|
|
15
15
|
* declared rather than inferred. Absent the flag, output is byte-for-byte what
|
|
16
16
|
* it was before, which keeps the default no-`--out` flow — where the neighbour
|
|
17
17
|
* is an uncompiled `icons.tsx` that only a bundler can resolve — working.
|
|
18
|
+
*
|
|
19
|
+
* The spawned-process block at the bottom pins what only real processes can
|
|
20
|
+
* prove: the emitted module actually loads under Node ESM, and the watch
|
|
21
|
+
* loop's child re-invocations carry the flag to every rebuild.
|
|
18
22
|
*/
|
|
19
23
|
|
|
20
24
|
import {describe, it, expect, beforeAll, beforeEach, afterEach} from 'vitest';
|
|
25
|
+
import {spawn, spawnSync} from 'node:child_process';
|
|
21
26
|
import * as fs from 'node:fs';
|
|
22
27
|
import * as path from 'node:path';
|
|
23
28
|
import * as os from 'node:os';
|
|
29
|
+
import {fileURLToPath, pathToFileURL} from 'node:url';
|
|
24
30
|
import {ensureCoreBuilt} from './ensure-core-built.mjs';
|
|
25
31
|
import {runCli} from '../../../test-utils/run-cli.mjs';
|
|
26
32
|
|
|
33
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
34
|
+
const CLI_BIN = path.resolve(__dirname, '../bin/astryx.mjs');
|
|
35
|
+
|
|
36
|
+
/** Poll until `predicate()` is true or the timeout elapses. */
|
|
37
|
+
async function waitFor(predicate, {timeout = 20000, interval = 100} = {}) {
|
|
38
|
+
const start = Date.now();
|
|
39
|
+
for (;;) {
|
|
40
|
+
if (predicate()) return true;
|
|
41
|
+
if (Date.now() - start > timeout) return false;
|
|
42
|
+
await new Promise(r => setTimeout(r, interval));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
27
46
|
/**
|
|
28
47
|
* The emitted icon import, or null. Reads the statement rather than the whole
|
|
29
48
|
* file: the `@generated` header quotes the source filename and a usage example,
|
|
@@ -142,13 +161,7 @@ describe('theme build --icons-specifier', () => {
|
|
|
142
161
|
const relativeTheme = path.relative(project, themeFile);
|
|
143
162
|
|
|
144
163
|
const built = await runCli(
|
|
145
|
-
[
|
|
146
|
-
'theme',
|
|
147
|
-
'build',
|
|
148
|
-
relativeTheme,
|
|
149
|
-
'--icons-specifier',
|
|
150
|
-
'./icons.mjs',
|
|
151
|
-
],
|
|
164
|
+
['theme', 'build', relativeTheme, '--icons-specifier', './icons.mjs'],
|
|
152
165
|
project,
|
|
153
166
|
);
|
|
154
167
|
expect(built.code).toBe(0);
|
|
@@ -223,3 +236,114 @@ describe('theme build --icons-specifier', () => {
|
|
|
223
236
|
expect(generated).not.toContain('icons:');
|
|
224
237
|
});
|
|
225
238
|
});
|
|
239
|
+
|
|
240
|
+
describe('theme build --icons-specifier (spawned processes)', () => {
|
|
241
|
+
it('emits a module Node can actually load', async () => {
|
|
242
|
+
const project = path.join(tmpDir, 'project');
|
|
243
|
+
const themeFile = writeThemeWithIcons(project, 'loadable');
|
|
244
|
+
|
|
245
|
+
const result = await runCli(
|
|
246
|
+
[
|
|
247
|
+
'theme',
|
|
248
|
+
'build',
|
|
249
|
+
path.relative(project, themeFile),
|
|
250
|
+
'--icons-specifier',
|
|
251
|
+
'./icons.mjs',
|
|
252
|
+
],
|
|
253
|
+
project,
|
|
254
|
+
);
|
|
255
|
+
expect(result.code).toBe(0);
|
|
256
|
+
|
|
257
|
+
// The text assertions above prove the emitted line; only a real Node
|
|
258
|
+
// process proves the module resolves and evaluates. That distinction is
|
|
259
|
+
// the regression #4620 shipped: every byte existed, none of them loaded.
|
|
260
|
+
const builtUrl = pathToFileURL(path.join(project, 'loadable.js'));
|
|
261
|
+
const probe = spawnSync(
|
|
262
|
+
process.execPath,
|
|
263
|
+
[
|
|
264
|
+
'--input-type=module',
|
|
265
|
+
'-e',
|
|
266
|
+
`const m = await import(${JSON.stringify(builtUrl.href)});` +
|
|
267
|
+
`if (m.loadableTheme?.name !== 'loadable') throw new Error('bad theme export');` +
|
|
268
|
+
`if (typeof m.testIcons !== 'object') throw new Error('bad registry export');`,
|
|
269
|
+
],
|
|
270
|
+
{encoding: 'utf8'},
|
|
271
|
+
);
|
|
272
|
+
expect(probe.stderr).toBe('');
|
|
273
|
+
expect(probe.status).toBe(0);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('watch mode forwards the flag to every rebuild', async () => {
|
|
277
|
+
const project = path.join(tmpDir, 'project');
|
|
278
|
+
const themeFile = writeThemeWithIcons(project, 'watched');
|
|
279
|
+
const cssFile = path.join(project, 'out', 'theme.css');
|
|
280
|
+
const builtFile = path.join(project, 'out', 'watched.js');
|
|
281
|
+
const declaredImport = 'import { testIcons } from "./icons.mjs";';
|
|
282
|
+
|
|
283
|
+
const child = spawn(
|
|
284
|
+
process.execPath,
|
|
285
|
+
[
|
|
286
|
+
CLI_BIN,
|
|
287
|
+
'theme',
|
|
288
|
+
'build',
|
|
289
|
+
path.relative(project, themeFile),
|
|
290
|
+
'--out',
|
|
291
|
+
'out/theme.css',
|
|
292
|
+
'--icons-specifier',
|
|
293
|
+
'./icons.mjs',
|
|
294
|
+
'--watch',
|
|
295
|
+
],
|
|
296
|
+
{cwd: project, env: {...process.env, FORCE_COLOR: '0'}},
|
|
297
|
+
);
|
|
298
|
+
let output = '';
|
|
299
|
+
child.stdout.on('data', d => (output += d.toString()));
|
|
300
|
+
child.stderr.on('data', d => (output += d.toString()));
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
// Initial build: the declared specifier reaches the module.
|
|
304
|
+
expect(await waitFor(() => fs.existsSync(cssFile))).toBe(true);
|
|
305
|
+
expect(await waitFor(() => /Watching/i.test(output))).toBe(true);
|
|
306
|
+
expect(iconImportLine(fs.readFileSync(builtFile, 'utf8'))).toBe(
|
|
307
|
+
declaredImport,
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
// Rebuilds run through a child re-invocation of `theme build`, so the
|
|
311
|
+
// flag reaches them only if the watch loop forwards it. Change a token
|
|
312
|
+
// and wait for the rebuilt CSS. fs.watch delivery is best-effort under
|
|
313
|
+
// load, so re-touch until the rebuild shows up (idempotent write).
|
|
314
|
+
const touched =
|
|
315
|
+
`import {testIcons} from './icons';\n` +
|
|
316
|
+
`export default {\n` +
|
|
317
|
+
` name: "watched",\n` +
|
|
318
|
+
` icons: testIcons,\n` +
|
|
319
|
+
` tokens: {'--color-bg': '#0a0b0c'},\n` +
|
|
320
|
+
`};\n`;
|
|
321
|
+
fs.writeFileSync(themeFile, touched);
|
|
322
|
+
const rebuilt = await waitFor(() => {
|
|
323
|
+
try {
|
|
324
|
+
if (fs.readFileSync(cssFile, 'utf-8').includes('#0a0b0c'))
|
|
325
|
+
return true;
|
|
326
|
+
} catch {
|
|
327
|
+
// CSS mid-write; fall through to re-touch.
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
fs.writeFileSync(themeFile, touched);
|
|
331
|
+
} catch {
|
|
332
|
+
// Retried on the next poll.
|
|
333
|
+
}
|
|
334
|
+
return false;
|
|
335
|
+
});
|
|
336
|
+
expect(rebuilt).toBe(true);
|
|
337
|
+
|
|
338
|
+
// The regenerated module still carries the declared specifier — the
|
|
339
|
+
// forwarding is what this test pins. A watch loop that dropped the flag
|
|
340
|
+
// would regenerate with the scraped './icons' here and ship the #4620
|
|
341
|
+
// bytes on every save.
|
|
342
|
+
expect(iconImportLine(fs.readFileSync(builtFile, 'utf8'))).toBe(
|
|
343
|
+
declaredImport,
|
|
344
|
+
);
|
|
345
|
+
} finally {
|
|
346
|
+
child.kill('SIGINT');
|
|
347
|
+
}
|
|
348
|
+
}, 60_000);
|
|
349
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astryxdesign/cli",
|
|
3
|
-
"version": "0.4.2-canary.
|
|
3
|
+
"version": "0.4.2-canary.da6ea67",
|
|
4
4
|
"displayName": "CLI",
|
|
5
5
|
"description": "Scaffold projects, browse templates, generate themes, and get agent-ready docs from the command line.",
|
|
6
6
|
"author": "Meta Open Source",
|
|
@@ -84,10 +84,10 @@
|
|
|
84
84
|
"zod": "^4.4.3"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
|
-
"@astryxdesign/charts": "0.4.2-canary.
|
|
88
|
-
"@astryxdesign/core": "0.4.2-canary.
|
|
89
|
-
"@astryxdesign/lab": "0.4.2-canary.
|
|
90
|
-
"@astryxdesign/theme-neutral": "0.4.2-canary.
|
|
87
|
+
"@astryxdesign/charts": "0.4.2-canary.da6ea67",
|
|
88
|
+
"@astryxdesign/core": "0.4.2-canary.da6ea67",
|
|
89
|
+
"@astryxdesign/lab": "0.4.2-canary.da6ea67",
|
|
90
|
+
"@astryxdesign/theme-neutral": "0.4.2-canary.da6ea67",
|
|
91
91
|
"gpt-tokenizer": "^3.4.0"
|
|
92
92
|
},
|
|
93
93
|
"peerDependenciesMeta": {
|
|
@@ -105,10 +105,10 @@
|
|
|
105
105
|
}
|
|
106
106
|
},
|
|
107
107
|
"devDependencies": {
|
|
108
|
-
"@astryxdesign/charts": "0.4.2-canary.
|
|
109
|
-
"@astryxdesign/core": "0.4.2-canary.
|
|
110
|
-
"@astryxdesign/lab": "0.4.2-canary.
|
|
111
|
-
"@astryxdesign/theme-neutral": "0.4.2-canary.
|
|
108
|
+
"@astryxdesign/charts": "0.4.2-canary.da6ea67",
|
|
109
|
+
"@astryxdesign/core": "0.4.2-canary.da6ea67",
|
|
110
|
+
"@astryxdesign/lab": "0.4.2-canary.da6ea67",
|
|
111
|
+
"@astryxdesign/theme-neutral": "0.4.2-canary.da6ea67",
|
|
112
112
|
"gpt-tokenizer": "^3.4.0"
|
|
113
113
|
},
|
|
114
114
|
"scripts": {
|