@astryxdesign/cli 0.4.7-canary.fe95cd5 → 0.5.0-canary.07f6c33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -0
- package/api/search/search.mjs +50 -4
- package/api/search/search.test.mjs +71 -0
- package/assets/codemods/__tests__/registry.test.mjs +1 -0
- package/assets/codemods/registry.mjs +1 -0
- package/assets/codemods/transforms/v0.5.0/__tests__/next-codemods.test.mjs +127 -0
- package/assets/codemods/transforms/v0.5.0/banner-collapsible-content.mjs +171 -0
- package/assets/codemods/transforms/v0.5.0/index.mjs +20 -0
- package/assets/templates/blocks/components/Banner/BannerCollapsibleContent.doc.mjs +1 -1
- package/assets/templates/blocks/components/Banner/BannerCollapsibleContent.tsx +1 -1
- package/assets/templates/pages/table-grouped/page.tsx +151 -144
- package/foundation/discovery/theming-targets.d.mts +38 -0
- package/foundation/discovery/theming-targets.mjs +67 -0
- package/foundation/discovery/theming-targets.test.mjs +119 -1
- package/package.json +9 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,69 @@
|
|
|
1
1
|
# @xds/cli
|
|
2
2
|
|
|
3
|
+
# 0.5.0
|
|
4
|
+
|
|
5
|
+
#### Breaking Changes
|
|
6
|
+
|
|
7
|
+
- Banner: the collapse axis moves onto one `collapsible` prop, and content can opt out of collapsing (#5255)
|
|
8
|
+
Banner inferred its disclosure from its content: any `children` got a chevron in the header and were hidden until it was pressed. There was no way to show content without a toggle — the case a banner most often wants, a list of the three fields that failed validation — and `defaultIsExpanded` was the only knob, with no controlled mode.
|
|
9
|
+
|
|
10
|
+
The whole axis is now one `boolean | CollapsibleConfig` prop, following the boolean-or-config convention `SideNav.collapsible` set, and backed by the shared `useCollapsible` hook rather than Banner's own state:
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
<Banner status="error" title="3 fields need attention">…</Banner> // unchanged: collapsible, starts closed
|
|
14
|
+
<Banner collapsible={false}>…</Banner> // new: always visible, no toggle
|
|
15
|
+
<Banner collapsible={{defaultIsOpen: true}}>…</Banner> // replaces defaultIsExpanded
|
|
16
|
+
<Banner collapsible={{isOpen, onOpenChange}}>…</Banner> // new: controlled
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
**The default is unchanged** — a banner that never mentioned `defaultIsExpanded` behaves exactly as it did. The breaking part is the prop itself: `defaultIsExpanded` is removed in favour of the config, which is a type error at every JSX call site that names it.
|
|
20
|
+
|
|
21
|
+
**Codemod:** `npx astryx upgrade --codemod banner-collapsible-content`
|
|
22
|
+
|
|
23
|
+
It rewrites `defaultIsExpanded` to `collapsible={{defaultIsOpen: true}}` and drops `defaultIsExpanded={false}`, which is now the default. Banners that never set the prop are left alone.
|
|
24
|
+
|
|
25
|
+
**One case the codemod and the compiler both miss: a spread.** `defaultIsExpanded` inside a props object is out of the transform's scope. A props object in a typed position still fails to compile — but an inferred one that is spread, `<Banner {...args} />`, does not, because TypeScript does not excess-property-check a spread. The prop then falls through to the DOM and the banner quietly starts collapsed. **Grep for `defaultIsExpanded` after running the codemod** and migrate any spread sites by hand.
|
|
26
|
+
|
|
27
|
+
#### New Components
|
|
28
|
+
|
|
29
|
+
- Promote `Stepper` and `Step` from the canary-only Lab package to Core. The stable package now ships their existing horizontal/vertical layouts, separated and on-track indicators, semantic status, density, and non-linear navigation, plus Core documentation and rendered examples. The default `aria-label` is now localized.
|
|
30
|
+
Advancing one step now animates the connector. Every connector the four layouts draw — the separated bars and the on-track segments alike — grows its accent fill out of the segment's leading edge instead of swapping a background color, so moving forward reads as progress travelling the track. That one gesture is the only thing that animates: going back, jumping forward by more than one step, and mounting mid-flow all apply at once, as does any change under `prefers-reduced-motion`. Retreats are deliberately instant — run in reverse the same transition ends on a shrinking stub of accent, and a remnant still on the track reads as unfinished where the identical curve growing forward reads as arrived — and multi-step jumps are instant because a jump is a navigation rather than a progression, so sweeping a front across the crossed segments only makes the user sit out a journey they asked to skip. Where one span is drawn by several segments (the on-track layouts split a span between two steps, three when a content slot sits between them) the segments take abutting slices of the span's time and run linearly, so the fill reads as one line growing at a constant speed rather than pieces lighting in turn.
|
|
31
|
+
|
|
32
|
+
Five visual fixes land with the promotion. Horizontal steps now divide the track evenly instead of sizing to their own labels, so every progress segment is the same width regardless of how long a step is named. Number indicators shrink from 20px to 16px to match the check, ring, and custom-icon indicators, so a step swapping its number for a check as it completes no longer nudges the label beside it. A step description now occupies a 16px box rather than a 24px one — it previously inherited the page's line box instead of applying its own leading, which opened an 8px gap under the label. A step's content slot now starts flush with the label above it at every density: the slot renders outside the density-padded label area, so it was hanging one pad short of it. And a vertical on-track step carrying content keeps its connector unbroken — the content renders below the row that draws the line, so the track used to split open around any step with content (#5201).
|
|
33
|
+
|
|
34
|
+
#### New Features
|
|
35
|
+
|
|
36
|
+
- AspectRatio: emit `ratio` as a class-level declaration instead of a hard inline style, so the ratio can be overridden responsively: StyleX consumers pass an `aspect-ratio` rule via `xstyle` (including under `@media`/`@container` conditions), and plain-CSS/Tailwind consumers override `aspect-ratio` from their own unlayered rules, which beat the `astryx-base` cascade layer regardless of specificity. The mixed-gallery template's hero now switches 3:1 to 3:2 when the grid stacks with a one-line override on a single element, replacing the duplicated hero markup the fixed inline ratio previously forced (#3883, closes #2798)
|
|
37
|
+
- CLI: `astryx theme targets` lists every component theming target — the `defineTheme` key, the class it paints, and the props and states it accepts — for one component or the whole system, with `--json` for lint and audit scripts. `astryx theme --help` now points at component overrides instead of reading as a build-tool menu. The listing and `theme build`'s override validation share one enumeration of the component docs, so neither can drift from the components (#5115).
|
|
38
|
+
|
|
39
|
+
#### Fixes
|
|
40
|
+
|
|
41
|
+
- neutral theme: darken the light-mode error red from `#e33f4a` to `#c9303a` so the filled `Badge variant="error"` label clears WCAG 2.1 AA. White on `#e33f4a` is 4.14:1 and the badge label is 12px/weight 500, so the 4.5:1 normal-text threshold applies rather than the 3:1 large-text allowance; `#c9303a` gives 5.29:1 while holding the hue (OKLCH H 21.9 -> 22.8, C 0.200 -> 0.189). StatusDot and the ProgressBar `--color-error` rebinding move with it — both are documented as tracking the badge fill so the dot and its badge read as one status language. Dark mode is untouched (dark text on `#ff705d`, 6.60:1). Adds `scripts/check-badge-contrast.test.mjs`, which resolves every theme's badge label/fill pair through `light-dark()`, `var()` indirection and alpha compositing, and holds all of them to 4.5:1 (#4446).
|
|
42
|
+
- Unified search and build now include components contributed by integrations, so a component registered through an integration is findable and buildable alongside the built-in set instead of silently missing from both (#5259).
|
|
43
|
+
- Table - Grouped page template: wrap the rows in `TableBody`
|
|
44
|
+
The template rendered `<TableRow>` straight into `<Table>`, so the emitted DOM was `<table><tr>`. `<table>` cannot contain a row directly: the HTML parser inserts an implied `<tbody>` when it parses server-rendered markup and React does not when it renders on the client, so anyone who copied the template into an app as a server-rendered page inherited a hydration mismatch in their own app. Client-only the DOM is still invalid — nothing reparents the rows, so the table ends up with `<tr>` children and no `<tbody>` at all, and any CSS or query aimed at `tbody` silently misses.
|
|
45
|
+
|
|
46
|
+
The rows now sit in `<TableBody>`, the same element the data-driven `data={...}` path renders, so styling, dividers, and column widths are unchanged (#5278).
|
|
47
|
+
|
|
48
|
+
#### Other Changes
|
|
49
|
+
|
|
50
|
+
- Public component theming vars are enumerable, and guarded against being documented but unsettable
|
|
51
|
+
`collectThemingVars` joins `collectThemingTargets` as part of the one enumeration the theming surface is read from. Two guards ride on it: a documented public var no component reads compiles to a declaration that never applies, and a var the component writes inline outranks every cascade layer, so no theme can reach it. Both had shipped; neither is visible in the generated theme CSS the jsdom suites assert on (#5409).
|
|
52
|
+
|
|
53
|
+
#### Contributors
|
|
54
|
+
|
|
55
|
+
Thanks to everyone who contributed to this release:
|
|
56
|
+
|
|
57
|
+
- @AKnassa
|
|
58
|
+
- @andrskr
|
|
59
|
+
- @cixzhang
|
|
60
|
+
- @ernestt
|
|
61
|
+
- @freddymeta
|
|
62
|
+
- @jiunshinn
|
|
63
|
+
- @rubyycheung
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
3
67
|
# 0.4.7
|
|
4
68
|
|
|
5
69
|
---
|
package/api/search/search.mjs
CHANGED
|
@@ -34,10 +34,12 @@ import {pathToFileURL} from 'node:url';
|
|
|
34
34
|
import {findCoreDir} from '../../foundation/fs/paths.mjs';
|
|
35
35
|
import {
|
|
36
36
|
discoverComponents,
|
|
37
|
+
discoverIntegrationComponents,
|
|
37
38
|
findComponentReadme,
|
|
38
39
|
resolveImportPath,
|
|
39
40
|
} from '../../foundation/discovery/component-discovery.mjs';
|
|
40
41
|
import {discoverHooks, findHookDoc} from '../../foundation/discovery/hook-discovery.mjs';
|
|
42
|
+
import {loadIntegrationsSafely} from '../component/_adapter.mjs';
|
|
41
43
|
import {levenshteinDistance} from '../../foundation/text/string-utils.mjs';
|
|
42
44
|
import {discoverTemplates, extractComponents} from '../template/template.mjs';
|
|
43
45
|
import {loadDocsCatalog, loadTopicDoc} from '../docs/_adapter.mjs';
|
|
@@ -350,12 +352,12 @@ async function loadModuleDoc(docPath, exportName = 'docs') {
|
|
|
350
352
|
}
|
|
351
353
|
|
|
352
354
|
/**
|
|
353
|
-
* Build component candidates: name + keywords +
|
|
354
|
-
* component's .doc.mjs.
|
|
355
|
+
* Build component candidates from core's own tree: name + keywords +
|
|
356
|
+
* usage/description from the component's .doc.mjs.
|
|
355
357
|
* @param {string} coreDir
|
|
356
358
|
* @returns {Promise<Candidate[]>}
|
|
357
359
|
*/
|
|
358
|
-
async function
|
|
360
|
+
async function gatherCoreComponents(coreDir) {
|
|
359
361
|
const grouped = discoverComponents(coreDir);
|
|
360
362
|
const names = Object.values(grouped).flat();
|
|
361
363
|
/** @type {Candidate[]} */
|
|
@@ -383,6 +385,50 @@ async function gatherComponents(coreDir) {
|
|
|
383
385
|
return candidates;
|
|
384
386
|
}
|
|
385
387
|
|
|
388
|
+
/**
|
|
389
|
+
* Build component candidates contributed by the project's configured
|
|
390
|
+
* integrations (astryx.config's `integrations`): name + keywords +
|
|
391
|
+
* usage/description from each component's .doc.mjs, same as core. Without
|
|
392
|
+
* this, an integration component is invisible to `search`/`build` even
|
|
393
|
+
* though `component --list`/`component <Name>` already resolve it — the two
|
|
394
|
+
* discovery paths silently disagreed.
|
|
395
|
+
* @param {string} cwd
|
|
396
|
+
* @returns {Promise<Candidate[]>}
|
|
397
|
+
*/
|
|
398
|
+
async function gatherIntegrationComponents(cwd) {
|
|
399
|
+
const loadedIntegrations = await loadIntegrationsSafely(cwd);
|
|
400
|
+
/** @type {Candidate[]} */
|
|
401
|
+
const candidates = [];
|
|
402
|
+
for (const integration of loadedIntegrations) {
|
|
403
|
+
for (const rec of discoverIntegrationComponents(integration)) {
|
|
404
|
+
const doc = await loadModuleDoc(rec.docPath);
|
|
405
|
+
candidates.push({
|
|
406
|
+
domain: 'component',
|
|
407
|
+
name: rec.name,
|
|
408
|
+
keywords: doc && Array.isArray(doc.keywords) ? doc.keywords : [],
|
|
409
|
+
description: doc ? doc.usage?.description || doc.description || '' : '',
|
|
410
|
+
_import: rec.package,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return candidates;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Build component candidates: core's own tree plus every configured
|
|
419
|
+
* integration's components.
|
|
420
|
+
* @param {string} coreDir
|
|
421
|
+
* @param {string} cwd
|
|
422
|
+
* @returns {Promise<Candidate[]>}
|
|
423
|
+
*/
|
|
424
|
+
async function gatherComponents(coreDir, cwd) {
|
|
425
|
+
const [core, integrations] = await Promise.all([
|
|
426
|
+
gatherCoreComponents(coreDir),
|
|
427
|
+
gatherIntegrationComponents(cwd),
|
|
428
|
+
]);
|
|
429
|
+
return [...core, ...integrations];
|
|
430
|
+
}
|
|
431
|
+
|
|
386
432
|
/**
|
|
387
433
|
* Build hook candidates: name + keywords + usage/description from the hook's
|
|
388
434
|
* .doc.mjs.
|
|
@@ -611,7 +657,7 @@ export async function search(query, options = {}) {
|
|
|
611
657
|
/** @param {string} d */
|
|
612
658
|
const wants = d => !type || type === d;
|
|
613
659
|
const [components, hooks, docTopics, templates] = await Promise.all([
|
|
614
|
-
wants('component') ? gatherComponents(coreDir) : [],
|
|
660
|
+
wants('component') ? gatherComponents(coreDir, cwd) : [],
|
|
615
661
|
wants('hook') ? gatherHooks(coreDir) : [],
|
|
616
662
|
wants('doc') ? gatherDocs(cwd) : [],
|
|
617
663
|
wants('template') ? gatherTemplates(cwd) : [],
|
|
@@ -10,9 +10,17 @@
|
|
|
10
10
|
* `limit`, an empty query, and a bad `--type` all throw AstryxError with the
|
|
11
11
|
* ERR_INVALID_ARGUMENT code, so a direct `@astryxdesign/cli/api` caller gets the
|
|
12
12
|
* same contract as `astryx search` on the command line.
|
|
13
|
+
*
|
|
14
|
+
* The last describe block covers integration-contributed components, using the
|
|
15
|
+
* same temp-consumer harness as template-integration.test.mjs. Before this,
|
|
16
|
+
* `search`/`build` only ever scanned @astryxdesign/core — an integration's own
|
|
17
|
+
* components were invisible to both, even though `component --list` and
|
|
18
|
+
* `component <Name>` already resolved them. The two discovery paths silently
|
|
19
|
+
* disagreed.
|
|
13
20
|
*/
|
|
14
21
|
|
|
15
22
|
import {describe, it, expect} from 'vitest';
|
|
23
|
+
import * as fs from 'node:fs';
|
|
16
24
|
import * as path from 'node:path';
|
|
17
25
|
import {fileURLToPath} from 'node:url';
|
|
18
26
|
import {search, SEARCH_DOMAINS} from './search.mjs';
|
|
@@ -92,3 +100,66 @@ describe('search leaf — limit validation (API matches the CLI contract)', () =
|
|
|
92
100
|
});
|
|
93
101
|
}, SLOW);
|
|
94
102
|
});
|
|
103
|
+
|
|
104
|
+
describe('search leaf — integration components', () => {
|
|
105
|
+
/**
|
|
106
|
+
* A minimal consumer project: a stub `@astryxdesign/core` (so `findCoreDir`
|
|
107
|
+
* resolves without needing the real package) plus an installed
|
|
108
|
+
* `@acme/widgets` integration that contributes one component.
|
|
109
|
+
*/
|
|
110
|
+
function makeConsumerWithIntegrationComponent() {
|
|
111
|
+
const dir = fs.mkdtempSync(path.join(process.cwd(), '.astryx-search-it-'));
|
|
112
|
+
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({name: 'consumer'}));
|
|
113
|
+
fs.writeFileSync(
|
|
114
|
+
path.join(dir, 'astryx.config.mjs'),
|
|
115
|
+
`export default { integrations: ['@acme/widgets'] };\n`,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// Stub core: just needs to exist with an (empty) src/ so discoverComponents
|
|
119
|
+
// doesn't throw. Its own component list is irrelevant to this test.
|
|
120
|
+
const coreDir = path.join(dir, 'node_modules', '@astryxdesign', 'core');
|
|
121
|
+
fs.mkdirSync(path.join(coreDir, 'src'), {recursive: true});
|
|
122
|
+
|
|
123
|
+
const widgetsDir = path.join(dir, 'node_modules', '@acme', 'widgets');
|
|
124
|
+
fs.mkdirSync(path.join(widgetsDir, 'components'), {recursive: true});
|
|
125
|
+
fs.writeFileSync(
|
|
126
|
+
path.join(widgetsDir, 'package.json'),
|
|
127
|
+
JSON.stringify({name: '@acme/widgets', version: '1.0.0'}),
|
|
128
|
+
);
|
|
129
|
+
fs.writeFileSync(
|
|
130
|
+
path.join(widgetsDir, 'astryx.integration.mjs'),
|
|
131
|
+
`export default { components: './components' };\n`,
|
|
132
|
+
);
|
|
133
|
+
fs.writeFileSync(
|
|
134
|
+
path.join(widgetsDir, 'components', 'FancyGizmo.doc.mjs'),
|
|
135
|
+
`export const docs = {
|
|
136
|
+
name: 'FancyGizmo',
|
|
137
|
+
keywords: ['gizmo', 'widget'],
|
|
138
|
+
usage: {description: 'A fancy gizmo widget.'},
|
|
139
|
+
};\n`,
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
return dir;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
it('includes a component contributed by a configured integration', async () => {
|
|
146
|
+
const dir = makeConsumerWithIntegrationComponent();
|
|
147
|
+
try {
|
|
148
|
+
const r = await search('gizmo', {cwd: dir, type: 'component'});
|
|
149
|
+
expect(r.data.results.some(x => x.name === 'FancyGizmo')).toBe(true);
|
|
150
|
+
} finally {
|
|
151
|
+
fs.rmSync(dir, {recursive: true, force: true});
|
|
152
|
+
}
|
|
153
|
+
}, SLOW);
|
|
154
|
+
|
|
155
|
+
it('reports the contributing package as the import hint', async () => {
|
|
156
|
+
const dir = makeConsumerWithIntegrationComponent();
|
|
157
|
+
try {
|
|
158
|
+
const r = await search('FancyGizmo', {cwd: dir, type: 'component'});
|
|
159
|
+
const hit = r.data.results.find(x => x.name === 'FancyGizmo');
|
|
160
|
+
expect(hit?.import).toBe('@acme/widgets');
|
|
161
|
+
} finally {
|
|
162
|
+
fs.rmSync(dir, {recursive: true, force: true});
|
|
163
|
+
}
|
|
164
|
+
}, SLOW);
|
|
165
|
+
});
|
|
@@ -27,6 +27,7 @@ const registry = new Map([
|
|
|
27
27
|
['0.2.1', () => import('./transforms/v0.2.1/index.mjs')],
|
|
28
28
|
['0.3.0', () => import('./transforms/v0.3.0/index.mjs')],
|
|
29
29
|
['0.4.0', () => import('./transforms/v0.4.0/index.mjs')],
|
|
30
|
+
['0.5.0', () => import('./transforms/v0.5.0/index.mjs')],
|
|
30
31
|
]);
|
|
31
32
|
|
|
32
33
|
// Re-export from the shared utility so registry callers and other consumers
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Unit tests for the staged (next-release) codemods.
|
|
5
|
+
*
|
|
6
|
+
* Mirrors v0.4.0/__tests__/next-codemods.test.mjs, which covers the codemods
|
|
7
|
+
* after promotion. Keeping a copy here means a staged transform is tested from
|
|
8
|
+
* the day it is written rather than the day it is released.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {describe, expect, it} from 'vitest';
|
|
12
|
+
import jscodeshift from 'jscodeshift';
|
|
13
|
+
|
|
14
|
+
const j = jscodeshift.withParser('tsx');
|
|
15
|
+
const api = {jscodeshift: j, stats: () => {}, report: () => {}};
|
|
16
|
+
|
|
17
|
+
async function apply(name, source) {
|
|
18
|
+
const {default: transform} = await import(`../${name}.mjs`);
|
|
19
|
+
return transform({source, path: 'test.tsx'}, api) ?? source;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const TRANSFORM = 'banner-collapsible-content';
|
|
23
|
+
|
|
24
|
+
const IMPORT = "import {Banner} from '@astryxdesign/core/Banner';\n";
|
|
25
|
+
|
|
26
|
+
describe('banner-collapsible-content', () => {
|
|
27
|
+
it('rewrites a bare defaultIsExpanded to a starts-open config', async () => {
|
|
28
|
+
const output = await apply(
|
|
29
|
+
TRANSFORM,
|
|
30
|
+
`${IMPORT}const el = <Banner status="info" title="T" defaultIsExpanded><p>d</p></Banner>;`,
|
|
31
|
+
);
|
|
32
|
+
expect(output).toContain('defaultIsOpen: true');
|
|
33
|
+
expect(output).not.toContain('defaultIsExpanded');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('rewrites defaultIsExpanded={true} the same way', async () => {
|
|
37
|
+
const output = await apply(
|
|
38
|
+
TRANSFORM,
|
|
39
|
+
`${IMPORT}const el = <Banner status="info" title="T" defaultIsExpanded={true}><p>d</p></Banner>;`,
|
|
40
|
+
);
|
|
41
|
+
expect(output).toContain('defaultIsOpen: true');
|
|
42
|
+
expect(output).not.toContain('defaultIsExpanded');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('drops defaultIsExpanded={false}, which is the default', async () => {
|
|
46
|
+
const output = await apply(
|
|
47
|
+
TRANSFORM,
|
|
48
|
+
`${IMPORT}const el = <Banner status="info" title="T" defaultIsExpanded={false}><p>d</p></Banner>;`,
|
|
49
|
+
);
|
|
50
|
+
expect(output).not.toContain('defaultIsExpanded');
|
|
51
|
+
// No config needed: starting collapsed is what a Banner does by default.
|
|
52
|
+
expect(output).not.toContain('collapsible');
|
|
53
|
+
// Untouched attributes keep their original text (recast only reprints
|
|
54
|
+
// what changed), so the element is exactly the base minus the prop.
|
|
55
|
+
expect(output).toContain('<Banner status="info" title="T">');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('keeps a dynamic default dynamic', async () => {
|
|
59
|
+
const output = await apply(
|
|
60
|
+
TRANSFORM,
|
|
61
|
+
`${IMPORT}const el = <Banner status="info" title="T" defaultIsExpanded={isOpen}><p>d</p></Banner>;`,
|
|
62
|
+
);
|
|
63
|
+
expect(output).toContain('defaultIsOpen: isOpen');
|
|
64
|
+
expect(output).not.toContain('defaultIsExpanded');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('leaves a banner that never set the prop alone', async () => {
|
|
68
|
+
// The default is unchanged, so this banner still behaves as it did. The
|
|
69
|
+
// migration must not touch it — that is the whole point of the shape.
|
|
70
|
+
const source = `${IMPORT}const el = <Banner status="error" title="T"><ul><li>a</li></ul></Banner>;`;
|
|
71
|
+
const output = await apply(TRANSFORM, source);
|
|
72
|
+
expect(output).toBe(source);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('leaves a childless banner alone', async () => {
|
|
76
|
+
const source = `${IMPORT}const el = <Banner status="info" title="T" />;`;
|
|
77
|
+
const output = await apply(TRANSFORM, source);
|
|
78
|
+
expect(output).toBe(source);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('leaves a banner that already uses collapsible alone', async () => {
|
|
82
|
+
const source = `${IMPORT}const el = <Banner status="info" title="T" collapsible={false} defaultIsExpanded><p>d</p></Banner>;`;
|
|
83
|
+
const output = await apply(TRANSFORM, source);
|
|
84
|
+
expect(output).toBe(source);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('does not guess around a spread', async () => {
|
|
88
|
+
const source = `${IMPORT}const el = <Banner status="info" title="T" defaultIsExpanded {...rest}><p>d</p></Banner>;`;
|
|
89
|
+
const output = await apply(TRANSFORM, source);
|
|
90
|
+
expect(output).toBe(source);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("leaves another component's defaultIsExpanded alone", async () => {
|
|
94
|
+
// ChatToolCalls has a prop of the same name that this migration must not
|
|
95
|
+
// touch.
|
|
96
|
+
const source = `import {ChatToolCalls} from '@astryxdesign/core/Chat';
|
|
97
|
+
const el = <ChatToolCalls calls={calls} defaultIsExpanded />;`;
|
|
98
|
+
const output = await apply(TRANSFORM, source);
|
|
99
|
+
expect(output).toBe(source);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('leaves a Banner that is not the core Banner alone', async () => {
|
|
103
|
+
const source = `import {Banner} from './ui/Banner';
|
|
104
|
+
const el = <Banner status="info" title="T" defaultIsExpanded><p>d</p></Banner>;`;
|
|
105
|
+
const output = await apply(TRANSFORM, source);
|
|
106
|
+
expect(output).toBe(source);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('leaves the prop inside a props object alone', async () => {
|
|
110
|
+
// Out of scope by design: which component the object feeds is a guess,
|
|
111
|
+
// and the removed prop makes those sites a type error anyway.
|
|
112
|
+
const source = `${IMPORT}const args = {status: 'info', title: 'T', defaultIsExpanded: true};
|
|
113
|
+
const el = <Banner {...args} />;`;
|
|
114
|
+
const output = await apply(TRANSFORM, source);
|
|
115
|
+
expect(output).toBe(source);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('migrates a Banner imported from the package root', async () => {
|
|
119
|
+
const output = await apply(
|
|
120
|
+
TRANSFORM,
|
|
121
|
+
`import {Banner, Button} from '@astryxdesign/core';
|
|
122
|
+
const el = <Banner status="info" title="T" defaultIsExpanded><p>d</p></Banner>;`,
|
|
123
|
+
);
|
|
124
|
+
expect(output).toContain('defaultIsOpen: true');
|
|
125
|
+
expect(output).not.toContain('defaultIsExpanded');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Codemod: migrate Banner's `defaultIsExpanded` onto `collapsible`
|
|
5
|
+
*
|
|
6
|
+
* Banner's collapse axis used to be a single knob, `defaultIsExpanded`, with
|
|
7
|
+
* the disclosure itself inferred from the presence of `children`. There was no
|
|
8
|
+
* way to have content without a toggle, and no controlled mode. The axis now
|
|
9
|
+
* lives on one `boolean | CollapsibleConfig` prop, per the boolean-or-config
|
|
10
|
+
* convention:
|
|
11
|
+
*
|
|
12
|
+
* <Banner>{children}</Banner> → collapsible, starts closed
|
|
13
|
+
* <Banner collapsible={{defaultIsOpen: true}}> → collapsible, starts open
|
|
14
|
+
* <Banner collapsible={{isOpen, onOpenChange}}> → controlled
|
|
15
|
+
* <Banner collapsible={false}> → always visible, no toggle
|
|
16
|
+
*
|
|
17
|
+
* The default is unchanged, so a Banner that never mentioned the old prop needs
|
|
18
|
+
* no rewrite at all — this transform is a prop rename and nothing more:
|
|
19
|
+
*
|
|
20
|
+
* defaultIsExpanded → collapsible={{defaultIsOpen: true}}
|
|
21
|
+
* defaultIsExpanded={true} → collapsible={{defaultIsOpen: true}}
|
|
22
|
+
* defaultIsExpanded={false} → (removed — it is the default)
|
|
23
|
+
* defaultIsExpanded={expr} → collapsible={{defaultIsOpen: expr}}
|
|
24
|
+
*
|
|
25
|
+
* Only elements named `Banner` are touched, and only when the file imports that
|
|
26
|
+
* name from `@astryxdesign/core`: `defaultIsExpanded` is also a ChatToolCalls
|
|
27
|
+
* prop, which this migration must leave alone.
|
|
28
|
+
*
|
|
29
|
+
* Scope: JSX attributes only. `defaultIsExpanded` inside a props object (a
|
|
30
|
+
* Storybook `args`, a spread built up in a variable) is left alone rather than
|
|
31
|
+
* rewritten on a guess about which component the object is for — removing the
|
|
32
|
+
* prop from the type makes those sites a type error, which is loud enough to
|
|
33
|
+
* find them.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
export const meta = {
|
|
37
|
+
title: "Rename Banner's `defaultIsExpanded` to the `collapsible` config",
|
|
38
|
+
description:
|
|
39
|
+
"Banner's collapse axis is now a single `collapsible?: boolean | " +
|
|
40
|
+
'CollapsibleConfig` prop. Rewrites `defaultIsExpanded` to the equivalent ' +
|
|
41
|
+
'config (`{defaultIsOpen: true}`), and drops ' +
|
|
42
|
+
'`defaultIsExpanded={false}`, which is the default. Banners that never ' +
|
|
43
|
+
'set the prop are untouched — the default is unchanged. Pass ' +
|
|
44
|
+
'`collapsible={false}` for content that is always visible.',
|
|
45
|
+
pr: '#5255',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const OLD_PROP = 'defaultIsExpanded';
|
|
49
|
+
const NEW_PROP = 'collapsible';
|
|
50
|
+
const COMPONENT = 'Banner';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Does this file use the core `Banner`?
|
|
54
|
+
*
|
|
55
|
+
* `defaultIsExpanded` is a ChatToolCalls prop too, and a local component may
|
|
56
|
+
* well be called Banner, so an unqualified element-name match is not enough.
|
|
57
|
+
*
|
|
58
|
+
* @param {any} j
|
|
59
|
+
* @param {any} root
|
|
60
|
+
* @returns {boolean}
|
|
61
|
+
*/
|
|
62
|
+
function importsCoreBanner(j, root) {
|
|
63
|
+
let found = false;
|
|
64
|
+
root.find(j.ImportDeclaration).forEach((/** @type {any} */ path) => {
|
|
65
|
+
const source = path.node.source?.value;
|
|
66
|
+
if (typeof source !== 'string' || !source.startsWith('@astryxdesign/core')) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
for (const spec of path.node.specifiers ?? []) {
|
|
70
|
+
if (
|
|
71
|
+
(spec.type === 'ImportSpecifier' && spec.imported?.name === COMPONENT) ||
|
|
72
|
+
(spec.local?.name === COMPONENT &&
|
|
73
|
+
(spec.type === 'ImportDefaultSpecifier' ||
|
|
74
|
+
spec.type === 'ImportSpecifier'))
|
|
75
|
+
) {
|
|
76
|
+
found = true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
return found;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {import('../../../../authoring/codemod/type').AstryxCodemodFile} file
|
|
85
|
+
* @param {import('../../../../authoring/codemod/type').CodemodTransformApi} api
|
|
86
|
+
* @returns {string | null | undefined}
|
|
87
|
+
*/
|
|
88
|
+
export default function transformer(file, api) {
|
|
89
|
+
// Cheap bail-out: nothing to rename without the old prop.
|
|
90
|
+
if (!file.source.includes(OLD_PROP)) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const j = api.jscodeshift;
|
|
95
|
+
const root = j(file.source);
|
|
96
|
+
|
|
97
|
+
if (!importsCoreBanner(j, root)) {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let hasChanges = false;
|
|
102
|
+
|
|
103
|
+
/** `collapsible={{defaultIsOpen: <expr>}}` */
|
|
104
|
+
const collapsibleWithDefault = (/** @type {any} */ expression) =>
|
|
105
|
+
j.jsxAttribute(
|
|
106
|
+
j.jsxIdentifier(NEW_PROP),
|
|
107
|
+
j.jsxExpressionContainer(
|
|
108
|
+
j.objectExpression([
|
|
109
|
+
j.objectProperty(j.identifier('defaultIsOpen'), expression),
|
|
110
|
+
]),
|
|
111
|
+
),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
root.find(j.JSXOpeningElement).forEach((/** @type {any} */ path) => {
|
|
115
|
+
const name = path.node.name;
|
|
116
|
+
if (name?.type !== 'JSXIdentifier' || name.name !== COMPONENT) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const attrs = path.node.attributes ?? [];
|
|
121
|
+
const oldIndex = attrs.findIndex(
|
|
122
|
+
(/** @type {any} */ a) =>
|
|
123
|
+
a.type === 'JSXAttribute' && a.name?.name === OLD_PROP,
|
|
124
|
+
);
|
|
125
|
+
if (oldIndex === -1) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Already migrated by hand, or a spread that may carry either prop:
|
|
130
|
+
// in both cases a rewrite would be guesswork.
|
|
131
|
+
const hasNewProp = attrs.some(
|
|
132
|
+
(/** @type {any} */ a) =>
|
|
133
|
+
a.type === 'JSXAttribute' && a.name?.name === NEW_PROP,
|
|
134
|
+
);
|
|
135
|
+
const hasSpread = attrs.some(
|
|
136
|
+
(/** @type {any} */ a) => a.type === 'JSXSpreadAttribute',
|
|
137
|
+
);
|
|
138
|
+
if (hasNewProp || hasSpread) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const value = attrs[oldIndex].value;
|
|
143
|
+
|
|
144
|
+
if (value == null) {
|
|
145
|
+
// Bare `defaultIsExpanded` — starts open.
|
|
146
|
+
attrs[oldIndex] = collapsibleWithDefault(j.booleanLiteral(true));
|
|
147
|
+
} else if (value.type === 'JSXExpressionContainer') {
|
|
148
|
+
const expression = value.expression;
|
|
149
|
+
const isBooleanLiteral =
|
|
150
|
+
expression.type === 'BooleanLiteral' ||
|
|
151
|
+
(expression.type === 'Literal' && typeof expression.value === 'boolean');
|
|
152
|
+
if (isBooleanLiteral && expression.value === false) {
|
|
153
|
+
// Starting closed is the default now, so the prop simply goes.
|
|
154
|
+
attrs.splice(oldIndex, 1);
|
|
155
|
+
} else if (isBooleanLiteral) {
|
|
156
|
+
attrs[oldIndex] = collapsibleWithDefault(j.booleanLiteral(true));
|
|
157
|
+
} else {
|
|
158
|
+
// A dynamic default stays dynamic.
|
|
159
|
+
attrs[oldIndex] = collapsibleWithDefault(expression);
|
|
160
|
+
}
|
|
161
|
+
} else {
|
|
162
|
+
// `defaultIsExpanded="something"` is not valid for a boolean prop;
|
|
163
|
+
// leave it for a human rather than inventing a meaning.
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
hasChanges = true;
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
return hasChanges ? root.toSource({quote: 'single'}) : undefined;
|
|
171
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file next transform manifest
|
|
5
|
+
*
|
|
6
|
+
* Staged codemods for the next release. The Version Packages PR promotes
|
|
7
|
+
* this file into the resolved version folder.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import bannerCollapsibleContent, {
|
|
11
|
+
meta as bannerCollapsibleContentMeta,
|
|
12
|
+
} from './banner-collapsible-content.mjs';
|
|
13
|
+
|
|
14
|
+
export default [
|
|
15
|
+
{
|
|
16
|
+
name: 'banner-collapsible-content',
|
|
17
|
+
transform: bannerCollapsibleContent,
|
|
18
|
+
meta: bannerCollapsibleContentMeta,
|
|
19
|
+
},
|
|
20
|
+
];
|
|
@@ -6,7 +6,7 @@ export const doc = {
|
|
|
6
6
|
exampleFor: 'Banner',
|
|
7
7
|
name: 'Banner — Collapsible',
|
|
8
8
|
displayName: 'Banner — Collapsible',
|
|
9
|
-
description: 'Combine an action button, dismiss control, and
|
|
9
|
+
description: 'Combine an action button, dismiss control, and a collapsible detail area in one banner. Children sit behind the toggle by default; `collapsible={{defaultIsOpen: true}}` starts it open, and `collapsible={false}` drops the toggle entirely. Use for complex notifications like config changes or deployment summaries.',
|
|
10
10
|
isReady: true,
|
|
11
11
|
aspectRatio: 16 / 9,
|
|
12
12
|
componentsUsed: ['Banner', 'Button', 'List', 'Layout', 'Text'],
|
|
@@ -16,7 +16,7 @@ export default function BannerCollapsibleContent() {
|
|
|
16
16
|
description="Review the changes before they take effect."
|
|
17
17
|
endContent={<Button label="Review" variant="secondary" size="sm" />}
|
|
18
18
|
isDismissable
|
|
19
|
-
|
|
19
|
+
collapsible={{defaultIsOpen: true}}>
|
|
20
20
|
<Stack direction="vertical" gap={2}>
|
|
21
21
|
<Text type="supporting" color="secondary">
|
|
22
22
|
Changed settings:
|
|
@@ -37,6 +37,7 @@ import {Divider} from '@astryxdesign/core/Divider';
|
|
|
37
37
|
import {MetadataList, MetadataListItem} from '@astryxdesign/core/MetadataList';
|
|
38
38
|
import {
|
|
39
39
|
Table,
|
|
40
|
+
TableBody,
|
|
40
41
|
TableRow,
|
|
41
42
|
TableCell,
|
|
42
43
|
proportional,
|
|
@@ -963,161 +964,167 @@ export default function DataTableTemplate() {
|
|
|
963
964
|
/>
|
|
964
965
|
))}
|
|
965
966
|
</colgroup>
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
967
|
+
<TableBody>
|
|
968
|
+
{groupKeys.map(key => {
|
|
969
|
+
const tasks = grouped.get(key);
|
|
970
|
+
if (!tasks || tasks.length === 0) {
|
|
971
|
+
return null;
|
|
972
|
+
}
|
|
973
|
+
const isExpanded = expandedGroups.has(key);
|
|
972
974
|
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
<TableRow
|
|
977
|
-
role="button"
|
|
978
|
-
tabIndex={0}
|
|
979
|
-
onClick={() => toggleGroup(key)}
|
|
980
|
-
onKeyDown={e => {
|
|
981
|
-
if (e.key === 'Enter' || e.key === ' ') {
|
|
982
|
-
e.preventDefault();
|
|
983
|
-
toggleGroup(key);
|
|
984
|
-
}
|
|
985
|
-
}}>
|
|
986
|
-
<TableCell colSpan={COL_COUNT} style={groupHeaderCell}>
|
|
987
|
-
<HStack gap={2} vAlign="center">
|
|
988
|
-
<Icon
|
|
989
|
-
icon={
|
|
990
|
-
isExpanded ? ChevronDownIcon : ChevronRightIcon
|
|
991
|
-
}
|
|
992
|
-
size="sm"
|
|
993
|
-
color="secondary"
|
|
994
|
-
/>
|
|
995
|
-
<Text type="body" weight="bold">
|
|
996
|
-
{getGroupLabel(groupBy, key)}
|
|
997
|
-
</Text>
|
|
998
|
-
<Badge
|
|
999
|
-
variant="neutral"
|
|
1000
|
-
label={String(tasks.length)}
|
|
1001
|
-
/>
|
|
1002
|
-
</HStack>
|
|
1003
|
-
</TableCell>
|
|
1004
|
-
</TableRow>
|
|
1005
|
-
)}
|
|
1006
|
-
{(groupBy === 'none' || isExpanded) &&
|
|
1007
|
-
tasks.map(task => (
|
|
975
|
+
return (
|
|
976
|
+
<React.Fragment key={key}>
|
|
977
|
+
{groupBy !== 'none' && (
|
|
1008
978
|
<TableRow
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
979
|
+
role="button"
|
|
980
|
+
tabIndex={0}
|
|
981
|
+
onClick={() => toggleGroup(key)}
|
|
982
|
+
onKeyDown={e => {
|
|
983
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
984
|
+
e.preventDefault();
|
|
985
|
+
toggleGroup(key);
|
|
986
|
+
}
|
|
987
|
+
}}>
|
|
988
|
+
<TableCell
|
|
989
|
+
colSpan={COL_COUNT}
|
|
990
|
+
style={groupHeaderCell}>
|
|
991
|
+
<HStack gap={2} vAlign="center">
|
|
1021
992
|
<Icon
|
|
1022
|
-
icon={
|
|
993
|
+
icon={
|
|
994
|
+
isExpanded
|
|
995
|
+
? ChevronDownIcon
|
|
996
|
+
: ChevronRightIcon
|
|
997
|
+
}
|
|
1023
998
|
size="sm"
|
|
1024
|
-
color=
|
|
999
|
+
color="secondary"
|
|
1025
1000
|
/>
|
|
1026
|
-
<Text type="
|
|
1027
|
-
{
|
|
1001
|
+
<Text type="body" weight="bold">
|
|
1002
|
+
{getGroupLabel(groupBy, key)}
|
|
1028
1003
|
</Text>
|
|
1029
|
-
<
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
<Text
|
|
1034
|
-
type="body"
|
|
1035
|
-
color="secondary"
|
|
1036
|
-
maxLines={1}>
|
|
1037
|
-
› {task.subtitle}
|
|
1038
|
-
</Text>
|
|
1039
|
-
)}
|
|
1004
|
+
<Badge
|
|
1005
|
+
variant="neutral"
|
|
1006
|
+
label={String(tasks.length)}
|
|
1007
|
+
/>
|
|
1040
1008
|
</HStack>
|
|
1041
1009
|
</TableCell>
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1010
|
+
</TableRow>
|
|
1011
|
+
)}
|
|
1012
|
+
{(groupBy === 'none' || isExpanded) &&
|
|
1013
|
+
tasks.map(task => (
|
|
1014
|
+
<TableRow
|
|
1015
|
+
key={task.id}
|
|
1016
|
+
onClick={() => setSelectedTask(task)}>
|
|
1017
|
+
<TableCell>
|
|
1018
|
+
<Center axis="horizontal">
|
|
1019
|
+
<StatusDot
|
|
1020
|
+
variant={STATUS_DOT_VARIANT[task.status]}
|
|
1021
|
+
label={STATUS_LABEL[task.status]}
|
|
1022
|
+
/>
|
|
1023
|
+
</Center>
|
|
1024
|
+
</TableCell>
|
|
1025
|
+
<TableCell>
|
|
1026
|
+
<HStack gap={3} vAlign="center">
|
|
1027
|
+
<Icon
|
|
1028
|
+
icon={ChartBarIcon}
|
|
1029
|
+
size="sm"
|
|
1030
|
+
color={PRIORITY_COLOR[task.priority]}
|
|
1031
|
+
/>
|
|
1032
|
+
<Text type="supporting" color="secondary">
|
|
1033
|
+
{task.taskId}
|
|
1034
|
+
</Text>
|
|
1035
|
+
<Text type="body" maxLines={1}>
|
|
1036
|
+
{task.title}
|
|
1037
|
+
</Text>
|
|
1038
|
+
{task.subtitle && (
|
|
1039
|
+
<Text
|
|
1040
|
+
type="body"
|
|
1041
|
+
color="secondary"
|
|
1042
|
+
maxLines={1}>
|
|
1043
|
+
› {task.subtitle}
|
|
1044
|
+
</Text>
|
|
1045
|
+
)}
|
|
1046
|
+
</HStack>
|
|
1047
|
+
</TableCell>
|
|
1048
|
+
<TableCell>
|
|
1049
|
+
{task.project ? (
|
|
1050
|
+
<Text type="body" maxLines={1}>
|
|
1051
|
+
{task.project}
|
|
1052
|
+
</Text>
|
|
1053
|
+
) : (
|
|
1054
|
+
<Text type="supporting" color="secondary">
|
|
1055
|
+
—
|
|
1056
|
+
</Text>
|
|
1057
|
+
)}
|
|
1058
|
+
</TableCell>
|
|
1059
|
+
<TableCell>
|
|
1060
|
+
<Text type="supporting" color="secondary">
|
|
1061
|
+
{task.created}
|
|
1046
1062
|
</Text>
|
|
1047
|
-
|
|
1063
|
+
</TableCell>
|
|
1064
|
+
<TableCell>
|
|
1048
1065
|
<Text type="supporting" color="secondary">
|
|
1049
|
-
|
|
1066
|
+
{task.updated}
|
|
1050
1067
|
</Text>
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
onClick: () => {},
|
|
1112
|
-
},
|
|
1113
|
-
]}
|
|
1114
|
-
/>
|
|
1115
|
-
</TableCell>
|
|
1116
|
-
</TableRow>
|
|
1117
|
-
))}
|
|
1118
|
-
</React.Fragment>
|
|
1119
|
-
);
|
|
1120
|
-
})}
|
|
1068
|
+
</TableCell>
|
|
1069
|
+
<TableCell>
|
|
1070
|
+
<Avatar name={task.assignee} size="sm" />
|
|
1071
|
+
</TableCell>
|
|
1072
|
+
<TableCell>
|
|
1073
|
+
<DropdownMenu
|
|
1074
|
+
button={{
|
|
1075
|
+
label: 'Actions',
|
|
1076
|
+
variant: 'ghost',
|
|
1077
|
+
size: 'sm',
|
|
1078
|
+
icon: (
|
|
1079
|
+
<Icon
|
|
1080
|
+
icon={EllipsisHorizontalIcon}
|
|
1081
|
+
size="sm"
|
|
1082
|
+
/>
|
|
1083
|
+
),
|
|
1084
|
+
isIconOnly: true,
|
|
1085
|
+
}}
|
|
1086
|
+
hasChevron={false}
|
|
1087
|
+
items={[
|
|
1088
|
+
{
|
|
1089
|
+
label: 'Edit issue',
|
|
1090
|
+
icon: PencilIcon,
|
|
1091
|
+
onClick: () => {},
|
|
1092
|
+
},
|
|
1093
|
+
{
|
|
1094
|
+
label: 'Assign to...',
|
|
1095
|
+
icon: UserIcon,
|
|
1096
|
+
onClick: () => {},
|
|
1097
|
+
},
|
|
1098
|
+
{
|
|
1099
|
+
label: 'Add label',
|
|
1100
|
+
icon: TagIcon,
|
|
1101
|
+
onClick: () => {},
|
|
1102
|
+
},
|
|
1103
|
+
{
|
|
1104
|
+
label: 'Duplicate',
|
|
1105
|
+
icon: DocumentDuplicateIcon,
|
|
1106
|
+
onClick: () => {},
|
|
1107
|
+
},
|
|
1108
|
+
{
|
|
1109
|
+
label: 'Move to project',
|
|
1110
|
+
icon: ArrowRightIcon,
|
|
1111
|
+
onClick: () => {},
|
|
1112
|
+
},
|
|
1113
|
+
{type: 'divider' as const},
|
|
1114
|
+
{
|
|
1115
|
+
label: 'Delete issue',
|
|
1116
|
+
icon: TrashIcon,
|
|
1117
|
+
onClick: () => {},
|
|
1118
|
+
},
|
|
1119
|
+
]}
|
|
1120
|
+
/>
|
|
1121
|
+
</TableCell>
|
|
1122
|
+
</TableRow>
|
|
1123
|
+
))}
|
|
1124
|
+
</React.Fragment>
|
|
1125
|
+
);
|
|
1126
|
+
})}
|
|
1127
|
+
</TableBody>
|
|
1121
1128
|
</Table>
|
|
1122
1129
|
</LayoutContent>
|
|
1123
1130
|
}
|
|
@@ -13,6 +13,23 @@
|
|
|
13
13
|
* @returns {Promise<ThemingTarget[]>}
|
|
14
14
|
*/
|
|
15
15
|
export function collectThemingTargets(coreSrc: string): Promise<ThemingTarget[]>;
|
|
16
|
+
/**
|
|
17
|
+
* One public custom property a theme may set on a component's target.
|
|
18
|
+
* @typedef {object} ThemingVar
|
|
19
|
+
* @property {string} name - the custom property, e.g. `--tree-list-indent`
|
|
20
|
+
* @property {string} component - the component whose doc declares it
|
|
21
|
+
* @property {string} dir - absolute path to the directory the doc lives in
|
|
22
|
+
* @property {string} default - the documented default value
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Every PUBLIC theming var declared under a core `src` directory, sorted by
|
|
26
|
+
* name. Private `--_*` vars are a component's own plumbing, not a theme's to
|
|
27
|
+
* set, so they are not enumerated here.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} coreSrc - absolute path to `<core>/src`
|
|
30
|
+
* @returns {Promise<ThemingVar[]>}
|
|
31
|
+
*/
|
|
32
|
+
export function collectThemingVars(coreSrc: string): Promise<ThemingVar[]>;
|
|
16
33
|
/**
|
|
17
34
|
* Collapse the enumeration into the `{key: [props and states]}` map theme
|
|
18
35
|
* validation checks override keys against — both are legal override keys, so
|
|
@@ -46,3 +63,24 @@ export type ThemingTarget = {
|
|
|
46
63
|
*/
|
|
47
64
|
states: string[];
|
|
48
65
|
};
|
|
66
|
+
/**
|
|
67
|
+
* One public custom property a theme may set on a component's target.
|
|
68
|
+
*/
|
|
69
|
+
export type ThemingVar = {
|
|
70
|
+
/**
|
|
71
|
+
* - the custom property, e.g. `--tree-list-indent`
|
|
72
|
+
*/
|
|
73
|
+
name: string;
|
|
74
|
+
/**
|
|
75
|
+
* - the component whose doc declares it
|
|
76
|
+
*/
|
|
77
|
+
component: string;
|
|
78
|
+
/**
|
|
79
|
+
* - absolute path to the directory the doc lives in
|
|
80
|
+
*/
|
|
81
|
+
dir: string;
|
|
82
|
+
/**
|
|
83
|
+
* - the documented default value
|
|
84
|
+
*/
|
|
85
|
+
default: string;
|
|
86
|
+
};
|
|
@@ -108,6 +108,73 @@ export async function collectThemingTargets(coreSrc) {
|
|
|
108
108
|
return targets;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/**
|
|
112
|
+
* One public custom property a theme may set on a component's target.
|
|
113
|
+
* @typedef {object} ThemingVar
|
|
114
|
+
* @property {string} name - the custom property, e.g. `--tree-list-indent`
|
|
115
|
+
* @property {string} component - the component whose doc declares it
|
|
116
|
+
* @property {string} dir - absolute path to the directory the doc lives in
|
|
117
|
+
* @property {string} default - the documented default value
|
|
118
|
+
*/
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Every PUBLIC theming var declared under a core `src` directory, sorted by
|
|
122
|
+
* name. Private `--_*` vars are a component's own plumbing, not a theme's to
|
|
123
|
+
* set, so they are not enumerated here.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} coreSrc - absolute path to `<core>/src`
|
|
126
|
+
* @returns {Promise<ThemingVar[]>}
|
|
127
|
+
*/
|
|
128
|
+
export async function collectThemingVars(coreSrc) {
|
|
129
|
+
if (!coreSrc || !fs.existsSync(coreSrc)) return [];
|
|
130
|
+
|
|
131
|
+
/** @type {Map<string, ThemingVar>} */
|
|
132
|
+
const vars = new Map();
|
|
133
|
+
|
|
134
|
+
/** @param {string} dir */
|
|
135
|
+
async function scan(dir) {
|
|
136
|
+
for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
|
|
137
|
+
const full = path.join(dir, entry.name);
|
|
138
|
+
if (entry.isDirectory()) {
|
|
139
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
140
|
+
await scan(full);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (!entry.name.endsWith('.doc.mjs')) continue;
|
|
144
|
+
|
|
145
|
+
/** @type {any} */
|
|
146
|
+
let doc;
|
|
147
|
+
try {
|
|
148
|
+
doc = await loadComponentDoc(full);
|
|
149
|
+
} catch {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const component =
|
|
154
|
+
typeof doc?.name === 'string' && doc.name
|
|
155
|
+
? doc.name
|
|
156
|
+
: path.basename(path.dirname(full));
|
|
157
|
+
|
|
158
|
+
for (const entryVar of doc?.theming?.vars || []) {
|
|
159
|
+
const name = entryVar?.name;
|
|
160
|
+
if (typeof name !== 'string') continue;
|
|
161
|
+
if (entryVar.private === true || name.startsWith('--_')) continue;
|
|
162
|
+
if (vars.has(name)) continue;
|
|
163
|
+
vars.set(name, {
|
|
164
|
+
name,
|
|
165
|
+
component,
|
|
166
|
+
dir: path.dirname(full),
|
|
167
|
+
default: typeof entryVar.default === 'string' ? entryVar.default : '',
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
await scan(coreSrc);
|
|
174
|
+
|
|
175
|
+
return [...vars.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
176
|
+
}
|
|
177
|
+
|
|
111
178
|
/**
|
|
112
179
|
* Collapse the enumeration into the `{key: [props and states]}` map theme
|
|
113
180
|
* validation checks override keys against — both are legal override keys, so
|
|
@@ -10,9 +10,19 @@
|
|
|
10
10
|
* while `theme targets` says another. That divergence is the failure the
|
|
11
11
|
* listing exists to prevent: a target list that can drift from the components
|
|
12
12
|
* is worse than no list.
|
|
13
|
+
*
|
|
14
|
+
* The public vars a target carries get the same treatment, one step further:
|
|
15
|
+
* being enumerable is not the same as being settable. A documented var no
|
|
16
|
+
* component reads compiles to a declaration that never applies (#5012), and a
|
|
17
|
+
* var the component writes inline outranks every cascade layer, so no theme can
|
|
18
|
+
* reach it (#4530). Both shipped. Neither is visible in the generated theme CSS
|
|
19
|
+
* — the artifact the jsdom suites assert on — so the wiring is checked here
|
|
20
|
+
* against source. Whether the cascade then lands the value on the element is a
|
|
21
|
+
* browser fact and no jsdom test can stand in for it.
|
|
13
22
|
*/
|
|
14
23
|
|
|
15
24
|
import {describe, it, expect} from 'vitest';
|
|
25
|
+
import * as fs from 'node:fs';
|
|
16
26
|
import * as path from 'node:path';
|
|
17
27
|
import {findCoreDir} from '../fs/paths.mjs';
|
|
18
28
|
import {
|
|
@@ -20,7 +30,11 @@ import {
|
|
|
20
30
|
findComponentReadme,
|
|
21
31
|
} from './component-discovery.mjs';
|
|
22
32
|
import {loadComponentDoc} from './component-loader.mjs';
|
|
23
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
collectThemingTargets,
|
|
35
|
+
collectThemingVars,
|
|
36
|
+
targetsByKey,
|
|
37
|
+
} from './theming-targets.mjs';
|
|
24
38
|
|
|
25
39
|
const coreDir = /** @type {string} */ (findCoreDir(process.cwd()));
|
|
26
40
|
const coreSrc = path.join(coreDir, 'src');
|
|
@@ -125,3 +139,107 @@ describe('collectThemingTargets', () => {
|
|
|
125
139
|
expect(missing).toEqual([]);
|
|
126
140
|
}, 60_000);
|
|
127
141
|
});
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Public vars — enumerable is not the same as settable
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
/** @type {Promise<import('./theming-targets.mjs').ThemingVar[]>} */
|
|
148
|
+
const enumeratedVars = collectThemingVars(coreSrc);
|
|
149
|
+
|
|
150
|
+
/** Every non-test source file under a component directory. */
|
|
151
|
+
function sourcesIn(dir) {
|
|
152
|
+
/** @type {string[]} */
|
|
153
|
+
const out = [];
|
|
154
|
+
for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
|
|
155
|
+
if (entry.isDirectory()) {
|
|
156
|
+
if (entry.name === 'node_modules' || entry.name === '__tests__') continue;
|
|
157
|
+
out.push(...sourcesIn(path.join(dir, entry.name)));
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (!/\.tsx?$/.test(entry.name)) continue;
|
|
161
|
+
if (/\.(test|stories)\.tsx?$/.test(entry.name)) continue;
|
|
162
|
+
out.push(path.join(dir, entry.name));
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The text of every inline style a file writes — `style={{…}}` objects and
|
|
169
|
+
* `setProperty` calls. A custom property written from either outranks every
|
|
170
|
+
* cascade layer, so a theme cannot reach it.
|
|
171
|
+
*/
|
|
172
|
+
function inlineStyleText(src) {
|
|
173
|
+
const chunks = [];
|
|
174
|
+
for (const m of src.matchAll(/style=\{\{/g)) {
|
|
175
|
+
const end = src.indexOf('}}', m.index);
|
|
176
|
+
chunks.push(src.slice(m.index, end === -1 ? src.length : end));
|
|
177
|
+
}
|
|
178
|
+
for (const m of src.matchAll(/setProperty\(\s*'[^']+'/g)) chunks.push(m[0]);
|
|
179
|
+
return chunks.join('\n');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
describe('collectThemingVars', () => {
|
|
183
|
+
it('enumerates the public vars and drops the private plumbing', async () => {
|
|
184
|
+
const names = (await enumeratedVars).map(v => v.name);
|
|
185
|
+
expect(names.length).toBeGreaterThan(0);
|
|
186
|
+
expect(names.every(n => !n.startsWith('--_'))).toBe(true);
|
|
187
|
+
expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b)));
|
|
188
|
+
expect(names).toEqual([...new Set(names)]);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('carries the component and the documented default', async () => {
|
|
192
|
+
const indent = (await enumeratedVars).find(
|
|
193
|
+
v => v.name === '--tree-list-indent',
|
|
194
|
+
);
|
|
195
|
+
expect(indent).toMatchObject({
|
|
196
|
+
name: '--tree-list-indent',
|
|
197
|
+
component: 'TreeList',
|
|
198
|
+
default: 'var(--spacing-4)',
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// #5012: the theme docs advertised `--button-press-scale`, which no component
|
|
203
|
+
// ever read. A theme setting it compiled to a declaration nothing consumed,
|
|
204
|
+
// and nothing failed — the var was in the docs, so every existence check
|
|
205
|
+
// passed. Reading it is the minimum that makes a documented var mean anything.
|
|
206
|
+
it('every documented var is read by the component that documents it', async () => {
|
|
207
|
+
/** @type {string[]} */
|
|
208
|
+
const unread = [];
|
|
209
|
+
for (const v of await enumeratedVars) {
|
|
210
|
+
const read = sourcesIn(v.dir).some(f =>
|
|
211
|
+
fs.readFileSync(f, 'utf-8').includes(`var(${v.name}`),
|
|
212
|
+
);
|
|
213
|
+
if (!read) unread.push(`${v.component}: nothing reads var(${v.name})`);
|
|
214
|
+
}
|
|
215
|
+
expect(
|
|
216
|
+
unread,
|
|
217
|
+
`A documented public var no component reads compiles to a declaration ` +
|
|
218
|
+
`that never applies (#5012). Either wire it up or drop it from the doc.`,
|
|
219
|
+
).toEqual([]);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// #4530: TreeList's indent was an inline `margin-inline-start` on the element
|
|
223
|
+
// carrying the theme target. An inline declaration outranks every cascade
|
|
224
|
+
// layer, so `@layer astryx-theme` could not reach it — the var was real, read,
|
|
225
|
+
// and documented, and still unsettable. The fix moved it into a StyleX rule.
|
|
226
|
+
it('no documented var is written inline, where no theme can outrank it', async () => {
|
|
227
|
+
/** @type {string[]} */
|
|
228
|
+
const clobbered = [];
|
|
229
|
+
for (const v of await enumeratedVars) {
|
|
230
|
+
for (const f of sourcesIn(v.dir)) {
|
|
231
|
+
if (inlineStyleText(fs.readFileSync(f, 'utf-8')).includes(v.name)) {
|
|
232
|
+
clobbered.push(
|
|
233
|
+
`${v.component}: ${path.basename(f)} sets ${v.name} inline`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
expect(
|
|
239
|
+
clobbered,
|
|
240
|
+
`An inline custom property beats every cascade layer, so a theme setting ` +
|
|
241
|
+
`it through @layer astryx-theme is silently ignored (#4530). Declare it ` +
|
|
242
|
+
`in a StyleX rule instead.`,
|
|
243
|
+
).toEqual([]);
|
|
244
|
+
});
|
|
245
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astryxdesign/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0-canary.07f6c33",
|
|
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",
|
|
@@ -87,10 +87,10 @@
|
|
|
87
87
|
"zod": "^4.4.3"
|
|
88
88
|
},
|
|
89
89
|
"peerDependencies": {
|
|
90
|
-
"@astryxdesign/charts": "0.
|
|
91
|
-
"@astryxdesign/core": "0.
|
|
92
|
-
"@astryxdesign/lab": "0.
|
|
93
|
-
"@astryxdesign/theme-neutral": "0.
|
|
90
|
+
"@astryxdesign/charts": "0.5.0-canary.07f6c33",
|
|
91
|
+
"@astryxdesign/core": "0.5.0-canary.07f6c33",
|
|
92
|
+
"@astryxdesign/lab": "0.5.0-canary.07f6c33",
|
|
93
|
+
"@astryxdesign/theme-neutral": "0.5.0-canary.07f6c33",
|
|
94
94
|
"gpt-tokenizer": "^3.4.0"
|
|
95
95
|
},
|
|
96
96
|
"peerDependenciesMeta": {
|
|
@@ -108,10 +108,10 @@
|
|
|
108
108
|
}
|
|
109
109
|
},
|
|
110
110
|
"devDependencies": {
|
|
111
|
-
"@astryxdesign/charts": "0.
|
|
112
|
-
"@astryxdesign/core": "0.
|
|
113
|
-
"@astryxdesign/lab": "0.
|
|
114
|
-
"@astryxdesign/theme-neutral": "0.
|
|
111
|
+
"@astryxdesign/charts": "0.5.0-canary.07f6c33",
|
|
112
|
+
"@astryxdesign/core": "0.5.0-canary.07f6c33",
|
|
113
|
+
"@astryxdesign/lab": "0.5.0-canary.07f6c33",
|
|
114
|
+
"@astryxdesign/theme-neutral": "0.5.0-canary.07f6c33",
|
|
115
115
|
"gpt-tokenizer": "^3.4.0"
|
|
116
116
|
},
|
|
117
117
|
"scripts": {
|