@astryxdesign/core 0.4.0 → 0.4.1
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 +39 -0
- package/dist/AspectRatio/AspectRatio.d.ts.map +1 -1
- package/dist/AspectRatio/AspectRatio.js +1 -2
- package/dist/AspectRatio/index.d.ts.map +1 -1
- package/dist/AspectRatio/index.js +1 -2
- package/dist/Badge/Badge.d.ts.map +1 -1
- package/dist/Badge/Badge.js +1 -2
- package/dist/Badge/index.d.ts.map +1 -1
- package/dist/Badge/index.js +1 -2
- package/dist/Banner/Banner.d.ts +3 -0
- package/dist/Banner/Banner.d.ts.map +1 -1
- package/dist/Banner/Banner.js +17 -3
- package/dist/Blockquote/Blockquote.d.ts.map +1 -1
- package/dist/Blockquote/Blockquote.js +1 -2
- package/dist/Blockquote/index.d.ts.map +1 -1
- package/dist/Blockquote/index.js +1 -2
- package/dist/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +2 -2
- package/dist/Card/Card.d.ts.map +1 -1
- package/dist/Card/Card.js +1 -2
- package/dist/Card/index.d.ts.map +1 -1
- package/dist/Card/index.js +1 -2
- package/dist/Center/Center.d.ts.map +1 -1
- package/dist/Center/Center.js +1 -2
- package/dist/Center/index.d.ts.map +1 -1
- package/dist/Center/index.js +1 -2
- package/dist/Code/Code.d.ts.map +1 -1
- package/dist/Code/Code.js +1 -2
- package/dist/CommandPalette/CommandPalette.d.ts.map +1 -1
- package/dist/CommandPalette/CommandPalette.js +5 -0
- package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
- package/dist/ComplexSelector/ComplexSelector.js +7 -8
- package/dist/DateInput/DateInput.d.ts.map +1 -1
- package/dist/DateInput/DateInput.js +23 -10
- package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
- package/dist/DateRangeInput/DateRangeInput.js +44 -16
- package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
- package/dist/DateTimeInput/DateTimeInput.js +32 -13
- package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts +1 -1
- package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuRadioGroup.js +10 -3
- package/dist/Field/PanelSearchInput.d.ts +81 -0
- package/dist/Field/PanelSearchInput.d.ts.map +1 -0
- package/dist/Field/PanelSearchInput.js +151 -0
- package/dist/FileInput/FileInput.d.ts.map +1 -1
- package/dist/FileInput/FileInput.js +21 -10
- package/dist/Grid/Grid.d.ts.map +1 -1
- package/dist/Grid/Grid.js +1 -2
- package/dist/Grid/index.d.ts.map +1 -1
- package/dist/Grid/index.js +1 -2
- package/dist/Icon/globalIconRegistry.d.ts +5 -4
- package/dist/Icon/globalIconRegistry.d.ts.map +1 -1
- package/dist/Icon/globalIconRegistry.js +5 -4
- package/dist/Indicator/index.d.ts +1 -1
- package/dist/Indicator/index.d.ts.map +1 -1
- package/dist/Indicator/types.d.ts +10 -0
- package/dist/Indicator/types.d.ts.map +1 -1
- package/dist/Lightbox/Lightbox.d.ts.map +1 -1
- package/dist/Lightbox/Lightbox.js +24 -50
- package/dist/MultiSelector/MultiSelector.d.ts +8 -1
- package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
- package/dist/MultiSelector/MultiSelector.js +134 -102
- package/dist/Popover/usePopover.d.ts +15 -0
- package/dist/Popover/usePopover.d.ts.map +1 -1
- package/dist/Popover/usePopover.js +14 -2
- package/dist/ProgressBar/ProgressBar.d.ts.map +1 -1
- package/dist/ProgressBar/ProgressBar.js +2 -3
- package/dist/Section/Section.d.ts.map +1 -1
- package/dist/Section/Section.js +1 -2
- package/dist/Section/index.d.ts.map +1 -1
- package/dist/Section/index.js +1 -2
- package/dist/Selector/Selector.d.ts +10 -0
- package/dist/Selector/Selector.d.ts.map +1 -1
- package/dist/Selector/Selector.js +135 -95
- package/dist/Skeleton/Skeleton.d.ts.map +1 -1
- package/dist/Skeleton/Skeleton.js +1 -2
- package/dist/Skeleton/index.d.ts.map +1 -1
- package/dist/Skeleton/index.js +1 -2
- package/dist/Slider/Slider.d.ts.map +1 -1
- package/dist/Slider/Slider.js +2 -6
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +3 -3
- package/dist/Table/TableRow.d.ts +1 -1
- package/dist/Table/TableRow.d.ts.map +1 -1
- package/dist/Table/TableRow.js +4 -2
- package/dist/TimeInput/TimeInput.d.ts.map +1 -1
- package/dist/TimeInput/TimeInput.js +7 -2
- package/dist/Timestamp/Timestamp.d.ts.map +1 -1
- package/dist/Timestamp/Timestamp.js +14 -3
- package/dist/Timestamp/formatInstant.d.ts +11 -1
- package/dist/Timestamp/formatInstant.d.ts.map +1 -1
- package/dist/Timestamp/formatInstant.js +3 -1
- package/dist/VisuallyHidden/VisuallyHidden.d.ts.map +1 -1
- package/dist/VisuallyHidden/VisuallyHidden.js +1 -2
- package/dist/VisuallyHidden/index.d.ts.map +1 -1
- package/dist/VisuallyHidden/index.js +1 -2
- package/dist/astryx.css +18 -17
- package/dist/astryx.umd.js +45 -45
- package/dist/astryx.umd.js.map +4 -4
- package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
- package/dist/hooks/useInputStatusIcon.js +17 -4
- package/dist/theme/defineTheme.d.ts +2 -2
- package/dist/theme/defineTheme.d.ts.map +1 -1
- package/dist/theme/defineTheme.js +2 -1
- package/dist/theme/index.d.ts +1 -1
- package/dist/theme/index.d.ts.map +1 -1
- package/dist/theme/index.js +1 -1
- package/dist/theme/tokens.stylex.d.ts +12 -0
- package/dist/theme/tokens.stylex.d.ts.map +1 -1
- package/dist/theme/tokens.stylex.js +23 -0
- package/dist/utils/focusOutline.stylex.d.ts +34 -19
- package/dist/utils/focusOutline.stylex.d.ts.map +1 -1
- package/dist/utils/focusOutline.stylex.js +46 -33
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +5 -1
- package/dist/utils/interactionModality.d.ts +34 -0
- package/dist/utils/interactionModality.d.ts.map +1 -0
- package/dist/utils/interactionModality.js +70 -0
- package/locales/en.json +40 -0
- package/locales/pseudo.json +30 -0
- package/package.json +2 -2
- package/src/AppShell/AppShell.doc.mjs +3 -0
- package/src/AspectRatio/AspectRatio.tsx +0 -2
- package/src/AspectRatio/index.ts +0 -2
- package/src/Badge/Badge.tsx +0 -2
- package/src/Badge/index.ts +0 -2
- package/src/Banner/Banner.test.tsx +58 -1
- package/src/Banner/Banner.tsx +24 -5
- package/src/Blockquote/Blockquote.tsx +0 -2
- package/src/Blockquote/index.ts +0 -2
- package/src/Button/Button.doc.mjs +2 -2
- package/src/Button/Button.tsx +5 -4
- package/src/Card/Card.tsx +0 -2
- package/src/Card/index.ts +0 -2
- package/src/Center/Center.tsx +0 -2
- package/src/Center/index.ts +0 -2
- package/src/CheckboxInput/CheckboxInput.test.tsx +8 -3
- package/src/Code/Code.tsx +0 -2
- package/src/CommandPalette/CommandPalette.test.tsx +54 -0
- package/src/CommandPalette/CommandPalette.tsx +5 -0
- package/src/ComplexSelector/ComplexSelector.test.tsx +15 -6
- package/src/ComplexSelector/ComplexSelector.tsx +6 -10
- package/src/DateInput/DateInput.test.tsx +19 -0
- package/src/DateInput/DateInput.tsx +3 -6
- package/src/DateRangeInput/DateRangeInput.tsx +3 -9
- package/src/DateTimeInput/DateTimeInput.test.tsx +79 -1
- package/src/DateTimeInput/DateTimeInput.tsx +23 -9
- package/src/DropdownMenu/DropdownMenuRadioGroup.tsx +2 -1
- package/src/Field/PanelSearchInput.tsx +271 -0
- package/src/FileInput/FileInput.tsx +21 -8
- package/src/Grid/Grid.tsx +0 -2
- package/src/Grid/index.ts +0 -2
- package/src/Icon/globalIconRegistry.tsx +5 -4
- package/src/Indicator/Indicator.doc.mjs +1 -1
- package/src/Indicator/index.ts +1 -0
- package/src/Indicator/types.ts +11 -0
- package/src/Lightbox/Lightbox.tsx +2 -11
- package/src/MultiSelector/MultiSelector.doc.mjs +9 -0
- package/src/MultiSelector/MultiSelector.test.tsx +135 -5
- package/src/MultiSelector/MultiSelector.tsx +170 -127
- package/src/Popover/Popover.doc.mjs +2 -0
- package/src/Popover/usePopover.tsx +37 -4
- package/src/ProgressBar/ProgressBar.tsx +2 -8
- package/src/Section/Section.tsx +0 -2
- package/src/Section/index.ts +0 -2
- package/src/Selector/Selector.doc.mjs +10 -0
- package/src/Selector/Selector.test.tsx +386 -2
- package/src/Selector/Selector.tsx +182 -122
- package/src/Skeleton/Skeleton.tsx +0 -2
- package/src/Skeleton/index.ts +0 -2
- package/src/Slider/Slider.tsx +2 -11
- package/src/Switch/Switch.tsx +10 -2
- package/src/Table/Table.test.tsx +31 -0
- package/src/Table/TableRow.tsx +6 -0
- package/src/TimeInput/TimeInput.test.tsx +54 -0
- package/src/TimeInput/TimeInput.tsx +17 -2
- package/src/Timestamp/Timestamp.test.tsx +91 -14
- package/src/Timestamp/Timestamp.tsx +14 -2
- package/src/Timestamp/formatInstant.ts +16 -1
- package/src/TopNav/TopNavMegaMenu.doc.mjs +5 -0
- package/src/TreeList/TreeList.test.tsx +3 -2
- package/src/VisuallyHidden/VisuallyHidden.tsx +0 -2
- package/src/VisuallyHidden/index.ts +0 -2
- package/src/hooks/useInputStatusIcon.tsx +8 -8
- package/src/serverSafeComponents.test.ts +665 -0
- package/src/theme/defineTheme.ts +3 -0
- package/src/theme/generateThemeRules.test.ts +17 -0
- package/src/theme/index.ts +2 -0
- package/src/theme/tokens.stylex.ts +18 -0
- package/src/theme/tokens.test.ts +37 -0
- package/src/utils/focusOutline.stylex.ts +37 -23
- package/src/utils/index.ts +4 -0
- package/src/utils/interactionModality.ts +74 -0
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Guards the RSC server/client boundary of every public component (#823).
|
|
5
|
+
* @input Reads packages/core/package.json export map + every source file under
|
|
6
|
+
* packages/core/src, parsed with the TypeScript compiler API
|
|
7
|
+
* @output Unit tests for the detection rules, plus two invariants tying the
|
|
8
|
+
* presence of `'use client'` to what a component's import graph actually needs
|
|
9
|
+
* @position Cross-cutting meta-test; sibling of scripts/check-use-client.mjs
|
|
10
|
+
*
|
|
11
|
+
* `scripts/check-use-client.mjs` enforces one direction only, and only
|
|
12
|
+
* directly: a file that *imports* a React client API must carry the directive.
|
|
13
|
+
* It is blind to two things this test covers:
|
|
14
|
+
*
|
|
15
|
+
* 1. Components carrying `'use client'` that no longer need it. A stale
|
|
16
|
+
* directive is not a lint error, but it pins the component to the client
|
|
17
|
+
* bundle and blocks it from ever resolving through a `react-server`
|
|
18
|
+
* export condition (#823 Phase 2).
|
|
19
|
+
* 2. Components that become client-only *transitively* — e.g. a directive-
|
|
20
|
+
* free `Badge.tsx` starting to import `Tooltip`. Badge itself imports no
|
|
21
|
+
* React client API, so check-use-client.mjs stays silent, yet Badge is no
|
|
22
|
+
* longer server-renderable.
|
|
23
|
+
*
|
|
24
|
+
* Both invariants below derive the server-safe set from the import graph, so
|
|
25
|
+
* the safe list is never written down twice — adding a hook (or a client
|
|
26
|
+
* import) to one of these components fails the test on its own.
|
|
27
|
+
*
|
|
28
|
+
* SYNC: When modified, update this header and scripts/check-use-client.mjs if
|
|
29
|
+
* the client-API list changes.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import {describe, it, expect} from 'vitest';
|
|
33
|
+
import {readdirSync, readFileSync, existsSync, statSync} from 'node:fs';
|
|
34
|
+
import {createRequire} from 'node:module';
|
|
35
|
+
import {join, dirname, resolve, relative} from 'node:path';
|
|
36
|
+
import ts from 'typescript';
|
|
37
|
+
|
|
38
|
+
const SRC_DIR = __dirname;
|
|
39
|
+
const PKG_JSON = join(SRC_DIR, '..', 'package.json');
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* React APIs that only exist in a client component. Kept in sync with
|
|
43
|
+
* `CLIENT_APIS` in scripts/check-use-client.mjs.
|
|
44
|
+
*/
|
|
45
|
+
const CLIENT_APIS = new Set([
|
|
46
|
+
'createContext',
|
|
47
|
+
'useContext',
|
|
48
|
+
// React 19's context read. `use(promise)` *is* legal on the server, so this
|
|
49
|
+
// over-approximates — but every `use()` call site in this package reads a
|
|
50
|
+
// context, which is exactly as client-only as useContext.
|
|
51
|
+
'use',
|
|
52
|
+
'useActionState',
|
|
53
|
+
'useState',
|
|
54
|
+
'useEffect',
|
|
55
|
+
'useEffectEvent',
|
|
56
|
+
'useRef',
|
|
57
|
+
'useCallback',
|
|
58
|
+
'useMemo',
|
|
59
|
+
'useReducer',
|
|
60
|
+
'useId',
|
|
61
|
+
'useTransition',
|
|
62
|
+
'useOptimistic',
|
|
63
|
+
'useSyncExternalStore',
|
|
64
|
+
'useLayoutEffect',
|
|
65
|
+
'useInsertionEffect',
|
|
66
|
+
'useImperativeHandle',
|
|
67
|
+
'useDeferredValue',
|
|
68
|
+
// The one non-hook export in react's client-only set.
|
|
69
|
+
'startTransition',
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
const EXTENSIONS = ['.tsx', '.ts', '.mjs', '.js', '.jsx'];
|
|
73
|
+
|
|
74
|
+
const isSourceFile = (name: string) =>
|
|
75
|
+
/\.[jt]sx?$/.test(name) &&
|
|
76
|
+
!/\.(test|test-violations|stories|doc|perf)\./.test(name) &&
|
|
77
|
+
!name.endsWith('.d.ts');
|
|
78
|
+
|
|
79
|
+
/** Resolve a relative import specifier the way a bundler would. */
|
|
80
|
+
function resolveSpecifier(spec: string, fromFile: string): string | null {
|
|
81
|
+
if (!spec.startsWith('.')) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
const base = resolve(dirname(fromFile), spec);
|
|
85
|
+
for (const ext of EXTENSIONS) {
|
|
86
|
+
if (existsSync(base + ext)) {
|
|
87
|
+
return base + ext;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (existsSync(base) && statSync(base).isDirectory()) {
|
|
91
|
+
for (const ext of EXTENSIONS) {
|
|
92
|
+
const index = join(base, `index${ext}`);
|
|
93
|
+
if (existsSync(index)) {
|
|
94
|
+
return index;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface ReExport {
|
|
102
|
+
target: string;
|
|
103
|
+
exportedName: string;
|
|
104
|
+
localName: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface ModuleInfo {
|
|
108
|
+
rel: string;
|
|
109
|
+
hasUseClient: boolean;
|
|
110
|
+
clientAPIs: string[];
|
|
111
|
+
reactDom: string[];
|
|
112
|
+
moduleMutableState: string[];
|
|
113
|
+
/** Plain imports: the names pulled, or '*' for default/namespace/side-effect. */
|
|
114
|
+
imports: {target: string; names: Set<string> | '*'}[];
|
|
115
|
+
reExports: ReExport[];
|
|
116
|
+
starReExports: string[];
|
|
117
|
+
/** True when every top-level statement is an import or a re-export. */
|
|
118
|
+
isPureBarrel: boolean;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const moduleCache = new Map<string, ModuleInfo>();
|
|
122
|
+
|
|
123
|
+
/** Every named binding in the clause is `type`-only, so the import is erased. */
|
|
124
|
+
function isTypeOnlyImport(clause: ts.ImportClause): boolean {
|
|
125
|
+
if (clause.isTypeOnly) {
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
if (clause.name) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
const bindings = clause.namedBindings;
|
|
132
|
+
if (!bindings || !ts.isNamedImports(bindings)) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
return (
|
|
136
|
+
bindings.elements.length > 0 &&
|
|
137
|
+
bindings.elements.every(element => element.isTypeOnly)
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function isTypeOnlyExport(node: ts.ExportDeclaration): boolean {
|
|
142
|
+
if (node.isTypeOnly) {
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
const clause = node.exportClause;
|
|
146
|
+
if (!clause || !ts.isNamedExports(clause)) {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
return (
|
|
150
|
+
clause.elements.length > 0 &&
|
|
151
|
+
clause.elements.every(element => element.isTypeOnly)
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function parseModule(file: string): ModuleInfo {
|
|
156
|
+
const cached = moduleCache.get(file);
|
|
157
|
+
if (cached) {
|
|
158
|
+
return cached;
|
|
159
|
+
}
|
|
160
|
+
const info = parseSource(readFileSync(file, 'utf-8'), file);
|
|
161
|
+
moduleCache.set(file, info);
|
|
162
|
+
return info;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Parse one module's source. Split from {@link parseModule} so the detection
|
|
167
|
+
* rules can be unit-tested against source strings rather than real files.
|
|
168
|
+
*/
|
|
169
|
+
function parseSource(text: string, file: string): ModuleInfo {
|
|
170
|
+
const sourceFile = ts.createSourceFile(
|
|
171
|
+
file,
|
|
172
|
+
text,
|
|
173
|
+
ts.ScriptTarget.ESNext,
|
|
174
|
+
true,
|
|
175
|
+
ts.ScriptKind.TSX,
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const info: ModuleInfo = {
|
|
179
|
+
rel: relative(SRC_DIR, file),
|
|
180
|
+
hasUseClient: false,
|
|
181
|
+
clientAPIs: [],
|
|
182
|
+
reactDom: [],
|
|
183
|
+
moduleMutableState: [],
|
|
184
|
+
imports: [],
|
|
185
|
+
reExports: [],
|
|
186
|
+
starReExports: [],
|
|
187
|
+
isPureBarrel: true,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// Directive prologue: only comments and blank lines may precede it, which
|
|
191
|
+
// the parser has already stripped by the time we see `statements`. Walk past
|
|
192
|
+
// *every* string-literal directive — stopping at the first non-`use client`
|
|
193
|
+
// one would let `'use strict';` hide the directive behind it.
|
|
194
|
+
for (const statement of sourceFile.statements) {
|
|
195
|
+
if (
|
|
196
|
+
ts.isExpressionStatement(statement) &&
|
|
197
|
+
ts.isStringLiteral(statement.expression)
|
|
198
|
+
) {
|
|
199
|
+
if (statement.expression.text === 'use client') {
|
|
200
|
+
info.hasUseClient = true;
|
|
201
|
+
}
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Local names bound to the react namespace: `import React from 'react'`. */
|
|
208
|
+
const reactNamespaces = new Set<string>();
|
|
209
|
+
|
|
210
|
+
for (const statement of sourceFile.statements) {
|
|
211
|
+
if (ts.isImportDeclaration(statement)) {
|
|
212
|
+
const spec = (statement.moduleSpecifier as ts.StringLiteral).text;
|
|
213
|
+
const clause = statement.importClause;
|
|
214
|
+
const typeOnly = clause ? isTypeOnlyImport(clause) : false;
|
|
215
|
+
|
|
216
|
+
if (spec === 'react' && clause && !clause.isTypeOnly) {
|
|
217
|
+
const reactBindings = clause.namedBindings;
|
|
218
|
+
if (reactBindings && ts.isNamedImports(reactBindings)) {
|
|
219
|
+
for (const element of reactBindings.elements) {
|
|
220
|
+
const imported = (element.propertyName ?? element.name).text;
|
|
221
|
+
if (!element.isTypeOnly && CLIENT_APIS.has(imported)) {
|
|
222
|
+
info.clientAPIs.push(imported);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// `import React from 'react'` and `import * as React from 'react'` put
|
|
227
|
+
// every client API one property access away, where the named scan above
|
|
228
|
+
// cannot see it. Record the local name; the sweep below finds the uses.
|
|
229
|
+
if (clause.name) {
|
|
230
|
+
reactNamespaces.add(clause.name.text);
|
|
231
|
+
}
|
|
232
|
+
if (reactBindings && ts.isNamespaceImport(reactBindings)) {
|
|
233
|
+
reactNamespaces.add(reactBindings.name.text);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (!typeOnly && spec.startsWith('react-dom')) {
|
|
237
|
+
info.reactDom.push(spec);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (!typeOnly) {
|
|
241
|
+
const target = resolveSpecifier(spec, file);
|
|
242
|
+
if (target) {
|
|
243
|
+
let names: Set<string> | '*' = '*';
|
|
244
|
+
const bindings = clause?.namedBindings;
|
|
245
|
+
if (bindings && ts.isNamedImports(bindings) && !clause?.name) {
|
|
246
|
+
names = new Set(
|
|
247
|
+
bindings.elements
|
|
248
|
+
.filter(element => !element.isTypeOnly)
|
|
249
|
+
.map(element => (element.propertyName ?? element.name).text),
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
info.imports.push({target, names});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (ts.isExportDeclaration(statement) && statement.moduleSpecifier) {
|
|
259
|
+
const spec = (statement.moduleSpecifier as ts.StringLiteral).text;
|
|
260
|
+
if (!isTypeOnlyExport(statement)) {
|
|
261
|
+
const target = resolveSpecifier(spec, file);
|
|
262
|
+
if (target) {
|
|
263
|
+
const clause = statement.exportClause;
|
|
264
|
+
if (clause && ts.isNamedExports(clause)) {
|
|
265
|
+
for (const element of clause.elements) {
|
|
266
|
+
if (element.isTypeOnly) {
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
info.reExports.push({
|
|
270
|
+
target,
|
|
271
|
+
exportedName: element.name.text,
|
|
272
|
+
localName: (element.propertyName ?? element.name).text,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
} else {
|
|
276
|
+
info.starReExports.push(target);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Anything else is real code, so this module is not a pass-through barrel.
|
|
284
|
+
info.isPureBarrel = false;
|
|
285
|
+
|
|
286
|
+
if (ts.isVariableStatement(statement)) {
|
|
287
|
+
const isLet = !(statement.declarationList.flags & ts.NodeFlags.Const);
|
|
288
|
+
for (const decl of statement.declarationList.declarations) {
|
|
289
|
+
const name = decl.name.getText(sourceFile);
|
|
290
|
+
if (isLet) {
|
|
291
|
+
info.moduleMutableState.push(`let ${name}`);
|
|
292
|
+
} else if (decl.initializer) {
|
|
293
|
+
const ctor = /^new (Map|Set|WeakMap|WeakSet)\b/.exec(
|
|
294
|
+
decl.initializer.getText(sourceFile),
|
|
295
|
+
);
|
|
296
|
+
if (ctor) {
|
|
297
|
+
info.moduleMutableState.push(`const ${name} = new ${ctor[1]}()`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Value-position `React.useState(...)`. A type reference like
|
|
305
|
+
// `React.ReactNode` parses as a QualifiedName rather than a
|
|
306
|
+
// PropertyAccessExpression, so types never reach this branch.
|
|
307
|
+
if (reactNamespaces.size > 0) {
|
|
308
|
+
const visit = (node: ts.Node): void => {
|
|
309
|
+
if (
|
|
310
|
+
ts.isPropertyAccessExpression(node) &&
|
|
311
|
+
ts.isIdentifier(node.expression) &&
|
|
312
|
+
reactNamespaces.has(node.expression.text) &&
|
|
313
|
+
CLIENT_APIS.has(node.name.text)
|
|
314
|
+
) {
|
|
315
|
+
info.clientAPIs.push(`${node.expression.text}.${node.name.text}`);
|
|
316
|
+
}
|
|
317
|
+
ts.forEachChild(node, visit);
|
|
318
|
+
};
|
|
319
|
+
ts.forEachChild(sourceFile, visit);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return info;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Walk the runtime import graph from `entry` and report every client-only
|
|
327
|
+
* surface it reaches.
|
|
328
|
+
*
|
|
329
|
+
* `packages/core/package.json` declares `sideEffects` as a narrow allowlist
|
|
330
|
+
* (`*.stylex.ts`, `componentStyles.ts`, `*.css`), so every other module is
|
|
331
|
+
* side-effect free and a bundler may drop unused re-exports. A pure
|
|
332
|
+
* pass-through barrel is therefore followed by *used export* rather than
|
|
333
|
+
* wholesale — otherwise importing `mergeProps` from `../utils` would appear to
|
|
334
|
+
* drag in every unrelated sibling the barrel happens to re-export.
|
|
335
|
+
*
|
|
336
|
+
* @param ownDir When set, `'use client'` directives on files directly inside
|
|
337
|
+
* this directory are ignored, so the walk reports what the component's code
|
|
338
|
+
* actually *needs* rather than what it is currently *marked* as.
|
|
339
|
+
*/
|
|
340
|
+
function findClientSurfaces(
|
|
341
|
+
entry: string,
|
|
342
|
+
ownDir: string | null,
|
|
343
|
+
): {reason: string; file: string; via: string}[] {
|
|
344
|
+
const problems: {reason: string; file: string; via: string}[] = [];
|
|
345
|
+
const visited = new Set<string>();
|
|
346
|
+
const queue: [string, Set<string> | '*', string[]][] = [
|
|
347
|
+
[entry, '*', [relative(SRC_DIR, entry)]],
|
|
348
|
+
];
|
|
349
|
+
|
|
350
|
+
while (queue.length > 0) {
|
|
351
|
+
const [file, names, via] = queue.pop()!;
|
|
352
|
+
if (!/\.(tsx?|jsx?|mjs)$/.test(file)) {
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
const key = `${file}|${names === '*' ? '*' : [...names].sort().join(',')}`;
|
|
356
|
+
if (visited.has(key)) {
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
visited.add(key);
|
|
360
|
+
|
|
361
|
+
const info = parseModule(file);
|
|
362
|
+
const chain = via.join(' -> ');
|
|
363
|
+
const insideOwnDir = ownDir !== null && dirname(file) === ownDir;
|
|
364
|
+
|
|
365
|
+
if (info.hasUseClient && !insideOwnDir) {
|
|
366
|
+
problems.push({
|
|
367
|
+
reason: 'depends on a "use client" module',
|
|
368
|
+
file: info.rel,
|
|
369
|
+
via: chain,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
if (info.clientAPIs.length > 0) {
|
|
373
|
+
const apis = [...new Set(info.clientAPIs)].join(', ');
|
|
374
|
+
problems.push({
|
|
375
|
+
reason: `imports ${apis} from react`,
|
|
376
|
+
file: info.rel,
|
|
377
|
+
via: chain,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
if (info.reactDom.length > 0) {
|
|
381
|
+
problems.push({
|
|
382
|
+
reason: `imports ${info.reactDom.join(', ')}`,
|
|
383
|
+
file: info.rel,
|
|
384
|
+
via: chain,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
if (info.moduleMutableState.length > 0) {
|
|
388
|
+
problems.push({
|
|
389
|
+
reason: `module-level mutable state (${info.moduleMutableState.join('; ')})`,
|
|
390
|
+
file: info.rel,
|
|
391
|
+
via: chain,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const push = (target: string, next: Set<string> | '*') =>
|
|
396
|
+
queue.push([target, next, [...via, relative(SRC_DIR, target)]]);
|
|
397
|
+
|
|
398
|
+
if (info.isPureBarrel && names !== '*') {
|
|
399
|
+
for (const wanted of names) {
|
|
400
|
+
const providers = info.reExports.filter(r => r.exportedName === wanted);
|
|
401
|
+
if (providers.length > 0) {
|
|
402
|
+
for (const p of providers) {
|
|
403
|
+
push(p.target, new Set([p.localName]));
|
|
404
|
+
}
|
|
405
|
+
} else {
|
|
406
|
+
for (const t of info.starReExports) {
|
|
407
|
+
push(t, new Set([wanted]));
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
for (const imp of info.imports) {
|
|
412
|
+
push(imp.target, imp.names);
|
|
413
|
+
}
|
|
414
|
+
} else {
|
|
415
|
+
for (const imp of info.imports) {
|
|
416
|
+
push(imp.target, imp.names);
|
|
417
|
+
}
|
|
418
|
+
for (const r of info.reExports) {
|
|
419
|
+
push(r.target, new Set([r.localName]));
|
|
420
|
+
}
|
|
421
|
+
for (const t of info.starReExports) {
|
|
422
|
+
push(t, '*');
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return problems;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
interface Component {
|
|
431
|
+
subpath: string;
|
|
432
|
+
dir: string;
|
|
433
|
+
entry: string;
|
|
434
|
+
/** Source files in the component's own directory that carry the directive. */
|
|
435
|
+
directiveFiles: string[];
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Every component the package exposes as a `./Name` subpath backed by
|
|
440
|
+
* `./src/Name/index.ts`. Driving off the export map keeps the test aligned
|
|
441
|
+
* with the package's real public surface.
|
|
442
|
+
*/
|
|
443
|
+
function publicComponents(): Component[] {
|
|
444
|
+
const pkg = JSON.parse(readFileSync(PKG_JSON, 'utf-8')) as {
|
|
445
|
+
exports?: Record<string, {source?: string}>;
|
|
446
|
+
};
|
|
447
|
+
const components: Component[] = [];
|
|
448
|
+
for (const [subpath, entryPoint] of Object.entries(pkg.exports ?? {})) {
|
|
449
|
+
const source = entryPoint?.source;
|
|
450
|
+
if (!source) {
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
const match = /^\.\/src\/([^/]+)\/index\.ts$/.exec(source);
|
|
454
|
+
if (!match) {
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
const dir = join(SRC_DIR, match[1]);
|
|
458
|
+
const entry = join(dir, 'index.ts');
|
|
459
|
+
if (!existsSync(entry)) {
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
const directiveFiles = readdirSync(dir)
|
|
463
|
+
.filter(isSourceFile)
|
|
464
|
+
.filter(name => parseModule(join(dir, name)).hasUseClient)
|
|
465
|
+
.map(name => `${match[1]}/${name}`);
|
|
466
|
+
components.push({subpath, dir, entry, directiveFiles});
|
|
467
|
+
}
|
|
468
|
+
return components;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const COMPONENTS = publicComponents();
|
|
472
|
+
|
|
473
|
+
const format = (problems: {reason: string; file: string; via: string}[]) =>
|
|
474
|
+
[...new Map(problems.map(p => [`${p.file}|${p.reason}`, p])).values()]
|
|
475
|
+
.map(p => ` ${p.file} ${p.reason}\n via ${p.via}`)
|
|
476
|
+
.join('\n');
|
|
477
|
+
|
|
478
|
+
/** Parse a source string as if it were a file inside `SRC_DIR`. */
|
|
479
|
+
const parse = (source: string) =>
|
|
480
|
+
parseSource(source, join(SRC_DIR, 'probe.tsx'));
|
|
481
|
+
|
|
482
|
+
describe('parseSource', () => {
|
|
483
|
+
it('reads a client API from a named react import', () => {
|
|
484
|
+
expect(
|
|
485
|
+
parse(`import {useState} from 'react';\nexport const a = 1;`).clientAPIs,
|
|
486
|
+
).toEqual(['useState']);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it('reads a client API reached through a default React binding', () => {
|
|
490
|
+
const info = parse(
|
|
491
|
+
`import React from 'react';\nexport const a = () => React.useState(0);`,
|
|
492
|
+
);
|
|
493
|
+
expect(info.clientAPIs).toEqual(['React.useState']);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it('reads a client API reached through a namespace React binding', () => {
|
|
497
|
+
const info = parse(
|
|
498
|
+
`import * as React from 'react';\nexport const C = React.createContext(false);`,
|
|
499
|
+
);
|
|
500
|
+
expect(info.clientAPIs).toEqual(['React.createContext']);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it('reads both halves of a mixed default-and-named react import', () => {
|
|
504
|
+
const info = parse(
|
|
505
|
+
`import React, {useRef} from 'react';\nexport const a = () => React.useState(0) && useRef(null);`,
|
|
506
|
+
);
|
|
507
|
+
expect(info.clientAPIs.sort()).toEqual(['React.useState', 'useRef']);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it('does not flag a React binding used only in type position', () => {
|
|
511
|
+
const info = parse(
|
|
512
|
+
`import * as React from 'react';\nexport type P = {a: React.ReactNode; b: React.KeyboardEvent};`,
|
|
513
|
+
);
|
|
514
|
+
expect(info.clientAPIs).toEqual([]);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it('treats React 19 `use` as a client API', () => {
|
|
518
|
+
// `use(Context)` is a context read, exactly as client-only as useContext.
|
|
519
|
+
expect(
|
|
520
|
+
parse(`import {use} from 'react';\nexport const a = 1;`).clientAPIs,
|
|
521
|
+
).toEqual(['use']);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it.each(['useActionState', 'useEffectEvent', 'startTransition'])(
|
|
525
|
+
'treats React 19 `%s` as a client API',
|
|
526
|
+
api => {
|
|
527
|
+
expect(
|
|
528
|
+
parse(`import {${api}} from 'react';\nexport const a = 1;`).clientAPIs,
|
|
529
|
+
).toEqual([api]);
|
|
530
|
+
},
|
|
531
|
+
);
|
|
532
|
+
|
|
533
|
+
it('reads a client API imported under an alias', () => {
|
|
534
|
+
expect(
|
|
535
|
+
parse(
|
|
536
|
+
`import {useActionState as useSubmitState} from 'react';\nexport const a = 1;`,
|
|
537
|
+
).clientAPIs,
|
|
538
|
+
).toEqual(['useActionState']);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it('reads useEffectEvent reached through a default React binding', () => {
|
|
542
|
+
expect(
|
|
543
|
+
parse(
|
|
544
|
+
`import React from 'react';\nexport const a = () => React.useEffectEvent(() => {});`,
|
|
545
|
+
).clientAPIs,
|
|
546
|
+
).toEqual(['React.useEffectEvent']);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it('sees `use client` behind another prologue directive', () => {
|
|
550
|
+
expect(
|
|
551
|
+
parse(`'use strict';\n'use client';\nexport const a = 1;`).hasUseClient,
|
|
552
|
+
).toBe(true);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it('ignores a `use client` string that follows real code', () => {
|
|
556
|
+
expect(parse(`export const a = 1;\n'use client';`).hasUseClient).toBe(
|
|
557
|
+
false,
|
|
558
|
+
);
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
describe('CLIENT_APIS', () => {
|
|
563
|
+
it('covers every client-only hook the react-server build omits', () => {
|
|
564
|
+
// React's own builds are the ground truth for "works on the server": the
|
|
565
|
+
// react-server condition maps to a deliberate allowlist, and the exports
|
|
566
|
+
// it drops are exactly the client-only surface. Every hook-shaped export
|
|
567
|
+
// in the client build but not the server build must be in CLIENT_APIS,
|
|
568
|
+
// or both invariants below go blind to it. The subset holds in one
|
|
569
|
+
// direction only: `use`, useId, useMemo and useCallback are server-legal
|
|
570
|
+
// but deliberately over-approximated as client APIs above.
|
|
571
|
+
const nodeRequire = createRequire(import.meta.url);
|
|
572
|
+
const cjs = join(dirname(nodeRequire.resolve('react/package.json')), 'cjs');
|
|
573
|
+
const exportsOf = (file: string) =>
|
|
574
|
+
new Set(
|
|
575
|
+
[
|
|
576
|
+
...readFileSync(join(cjs, file), 'utf-8').matchAll(
|
|
577
|
+
/exports\.(\w+)\s*=/g,
|
|
578
|
+
),
|
|
579
|
+
].map(m => m[1]),
|
|
580
|
+
);
|
|
581
|
+
const client = exportsOf('react.production.js');
|
|
582
|
+
const server = exportsOf('react.react-server.production.js');
|
|
583
|
+
const missing = [...client].filter(
|
|
584
|
+
name =>
|
|
585
|
+
(/^use[A-Z]/.test(name) || name === 'startTransition') &&
|
|
586
|
+
!server.has(name) &&
|
|
587
|
+
!CLIENT_APIS.has(name),
|
|
588
|
+
);
|
|
589
|
+
expect(
|
|
590
|
+
missing,
|
|
591
|
+
`\nreact marks these exports client-only (absent from its react-server\n` +
|
|
592
|
+
`build) but CLIENT_APIS does not list them. Add them here and to\n` +
|
|
593
|
+
`scripts/check-use-client.mjs:\n ${missing.join(', ')}\n`,
|
|
594
|
+
).toEqual([]);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
it('stays in sync with scripts/check-use-client.mjs', () => {
|
|
598
|
+
const script = readFileSync(
|
|
599
|
+
join(SRC_DIR, '..', '..', '..', 'scripts', 'check-use-client.mjs'),
|
|
600
|
+
'utf-8',
|
|
601
|
+
);
|
|
602
|
+
const literal = /const CLIENT_APIS = \[([\s\S]*?)\];/.exec(script);
|
|
603
|
+
const names =
|
|
604
|
+
literal === null
|
|
605
|
+
? []
|
|
606
|
+
: [
|
|
607
|
+
...literal[1]
|
|
608
|
+
.split('\n')
|
|
609
|
+
.filter(line => !line.trim().startsWith('//'))
|
|
610
|
+
.join('\n')
|
|
611
|
+
.matchAll(/'([^']+)'/g),
|
|
612
|
+
].map(m => m[1]);
|
|
613
|
+
expect(new Set(names)).toEqual(CLIENT_APIS);
|
|
614
|
+
});
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
describe('RSC server/client boundary (#823)', () => {
|
|
618
|
+
it('finds the public component entry points', () => {
|
|
619
|
+
// The export map lists 100+ component subpaths; a floor this close to the
|
|
620
|
+
// real count catches a resolution bug that silently shrinks the covered set
|
|
621
|
+
// and turns both invariants below into no-ops.
|
|
622
|
+
expect(COMPONENTS.length).toBeGreaterThan(95);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it('does not mark server-safe components as "use client"', () => {
|
|
626
|
+
const stale: string[] = [];
|
|
627
|
+
for (const component of COMPONENTS) {
|
|
628
|
+
if (component.directiveFiles.length === 0) {
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
// Ignore this component's own directives so the graph reports what its
|
|
632
|
+
// code needs, not what it is currently labelled.
|
|
633
|
+
const problems = findClientSurfaces(component.entry, component.dir);
|
|
634
|
+
if (problems.length === 0) {
|
|
635
|
+
stale.push(
|
|
636
|
+
` ${component.subpath} is server-safe but still carries 'use client' in: ${component.directiveFiles.join(', ')}`,
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
expect(
|
|
641
|
+
stale.join('\n'),
|
|
642
|
+
`\nThese components import no client-only surface, so the directive pins them\n` +
|
|
643
|
+
`to the client bundle for nothing. Remove it (source file *and* index.ts):\n`,
|
|
644
|
+
).toBe('');
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it('keeps directive-free components free of client-only imports', () => {
|
|
648
|
+
const leaked: string[] = [];
|
|
649
|
+
for (const component of COMPONENTS) {
|
|
650
|
+
if (component.directiveFiles.length > 0) {
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
const problems = findClientSurfaces(component.entry, null);
|
|
654
|
+
if (problems.length > 0) {
|
|
655
|
+
leaked.push(` ${component.subpath}:\n${format(problems)}`);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
expect(
|
|
659
|
+
leaked.join('\n'),
|
|
660
|
+
`\nThese components ship without 'use client', so they are server modules —\n` +
|
|
661
|
+
`but they reach a client-only surface. Either drop the client dependency or\n` +
|
|
662
|
+
`restore the directive. scripts/check-use-client.mjs cannot see this:\n`,
|
|
663
|
+
).toBe('');
|
|
664
|
+
});
|
|
665
|
+
});
|
package/src/theme/defineTheme.ts
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
colorDefaults,
|
|
43
43
|
spacingDefaults,
|
|
44
44
|
sizeDefaults,
|
|
45
|
+
focusDefaults,
|
|
45
46
|
radiusDefaults,
|
|
46
47
|
shadowDefaults,
|
|
47
48
|
durationDefaults,
|
|
@@ -74,6 +75,7 @@ export type CoreTokenName =
|
|
|
74
75
|
| keyof typeof colorDefaults
|
|
75
76
|
| keyof typeof spacingDefaults
|
|
76
77
|
| keyof typeof sizeDefaults
|
|
78
|
+
| keyof typeof focusDefaults
|
|
77
79
|
| keyof typeof radiusDefaults
|
|
78
80
|
| keyof typeof shadowDefaults
|
|
79
81
|
| keyof typeof durationDefaults
|
|
@@ -377,6 +379,7 @@ export const tokenDefaults: Record<string, string> = {
|
|
|
377
379
|
...colorDefaults,
|
|
378
380
|
...spacingDefaults,
|
|
379
381
|
...sizeDefaults,
|
|
382
|
+
...focusDefaults,
|
|
380
383
|
...radiusDefaults,
|
|
381
384
|
...shadowDefaults,
|
|
382
385
|
...durationDefaults,
|
|
@@ -23,6 +23,23 @@ const defaultInput = {
|
|
|
23
23
|
},
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
+
describe('focus outline tokens', () => {
|
|
27
|
+
it('emits a focus ring override into the theme scope', () => {
|
|
28
|
+
const theme = defineTheme({
|
|
29
|
+
name: 'brand',
|
|
30
|
+
tokens: {
|
|
31
|
+
'--focus-outline-color': '#FF00FF',
|
|
32
|
+
'--focus-outline-width': '4px',
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const {component} = generateThemeCSS(theme);
|
|
37
|
+
|
|
38
|
+
expect(component).toContain('--focus-outline-color: #FF00FF;');
|
|
39
|
+
expect(component).toContain('--focus-outline-width: 4px;');
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
26
43
|
describe('generateThemeRules', () => {
|
|
27
44
|
const theme = defineTheme(defaultInput);
|
|
28
45
|
const rules = generateThemeRules(theme);
|