@astryxdesign/core 0.4.2 → 0.4.3-canary.00bed29
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 +45 -0
- package/README.md +79 -45
- package/dist/Avatar/Avatar.d.ts.map +1 -1
- package/dist/Avatar/Avatar.js +3 -19
- package/dist/Banner/Banner.d.ts +9 -2
- package/dist/Banner/Banner.d.ts.map +1 -1
- package/dist/Banner/Banner.js +84 -33
- package/dist/Banner/index.d.ts +14 -12
- package/dist/Banner/index.d.ts.map +1 -1
- package/dist/Banner/index.js +10 -8
- package/dist/BottomSheet/BottomSheet.d.ts +66 -0
- package/dist/BottomSheet/BottomSheet.d.ts.map +1 -0
- package/dist/BottomSheet/BottomSheet.js +392 -0
- package/dist/BottomSheet/BottomSheetPanel.d.ts +60 -0
- package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -0
- package/dist/BottomSheet/BottomSheetPanel.js +398 -0
- package/dist/BottomSheet/BottomSheetSwitcher.d.ts +61 -0
- package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -0
- package/dist/BottomSheet/BottomSheetSwitcher.js +410 -0
- package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts +21 -0
- package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts.map +1 -0
- package/dist/BottomSheet/BottomSheetSwitcherContext.js +13 -0
- package/dist/BottomSheet/index.d.ts +11 -0
- package/dist/BottomSheet/index.d.ts.map +1 -0
- package/dist/BottomSheet/index.js +12 -0
- package/dist/BottomSheet/snapOffsets.d.ts +72 -0
- package/dist/BottomSheet/snapOffsets.d.ts.map +1 -0
- package/dist/BottomSheet/snapOffsets.js +130 -0
- package/dist/BottomSheet/useMobileKeyboard.d.ts +35 -0
- package/dist/BottomSheet/useMobileKeyboard.d.ts.map +1 -0
- package/dist/BottomSheet/useMobileKeyboard.js +373 -0
- package/dist/BottomSheet/useSheetGestures.d.ts +180 -0
- package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -0
- package/dist/BottomSheet/useSheetGestures.js +942 -0
- package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
- package/dist/Chat/ChatComposerInput.js +3 -4
- package/dist/Chat/ChatTokenizedText.js +1 -1
- package/dist/ComplexSelector/ComplexSelector.d.ts +38 -4
- package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
- package/dist/ComplexSelector/ComplexSelector.js +98 -31
- package/dist/ComplexSelector/index.d.ts +2 -2
- package/dist/ComplexSelector/index.d.ts.map +1 -1
- package/dist/ComplexSelector/index.js +1 -1
- package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
- package/dist/ContextMenu/ContextMenu.js +4 -2
- package/dist/DateInput/DateInput.d.ts.map +1 -1
- package/dist/DateInput/DateInput.js +9 -1
- package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
- package/dist/DateTimeInput/DateTimeInput.js +31 -1
- package/dist/Dialog/Dialog.d.ts.map +1 -1
- package/dist/Dialog/Dialog.js +2 -1
- package/dist/Lightbox/Lightbox.d.ts.map +1 -1
- package/dist/Lightbox/Lightbox.js +9 -3
- package/dist/Markdown/parser.d.ts.map +1 -1
- package/dist/Markdown/parser.js +55 -12
- package/dist/MobileNav/MobileNav.d.ts +12 -0
- package/dist/MobileNav/MobileNav.d.ts.map +1 -1
- package/dist/MobileNav/MobileNav.js +102 -30
- package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
- package/dist/MultiSelector/MultiSelector.js +21 -7
- package/dist/NumberInput/NumberInput.d.ts.map +1 -1
- package/dist/NumberInput/NumberInput.js +9 -1
- package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
- package/dist/PowerSearch/PowerSearch.js +4 -1
- package/dist/PowerSearch/PowerSearchEditPopover.js +1 -1
- package/dist/PowerSearch/formatFilterValue.d.ts.map +1 -1
- package/dist/PowerSearch/formatFilterValue.js +2 -4
- package/dist/Selector/Selector.d.ts.map +1 -1
- package/dist/Selector/Selector.js +14 -3
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +5 -1
- package/dist/Table/columnUtils.d.ts.map +1 -1
- package/dist/Table/columnUtils.js +4 -1
- package/dist/TextArea/TextArea.d.ts +6 -3
- package/dist/TextArea/TextArea.d.ts.map +1 -1
- package/dist/TextArea/TextArea.js +17 -6
- package/dist/TimeInput/TimeInput.d.ts.map +1 -1
- package/dist/TimeInput/TimeInput.js +8 -1
- package/dist/Tokenizer/Tokenizer.js +11 -5
- package/dist/Tooltip/useTooltip.d.ts.map +1 -1
- package/dist/Tooltip/useTooltip.js +4 -1
- package/dist/TreeList/TreeList.js +2 -1
- package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
- package/dist/Typeahead/BaseTypeahead.js +5 -3
- package/dist/Typeahead/Typeahead.d.ts.map +1 -1
- package/dist/Typeahead/Typeahead.js +10 -1
- package/dist/astryx.css +25 -2
- package/dist/hooks/index.d.ts +8 -1
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +8 -1
- package/dist/hooks/useFocusTrap.d.ts +0 -11
- package/dist/hooks/useFocusTrap.d.ts.map +1 -1
- package/dist/hooks/useFocusTrap.js +1 -11
- package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
- package/dist/hooks/useInputStatusIcon.js +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/theme/defineTheme.d.ts +30 -7
- package/dist/theme/defineTheme.d.ts.map +1 -1
- package/dist/theme/defineTheme.js +36 -47
- package/dist/theme/expandColorScale.d.ts +17 -2
- package/dist/theme/expandColorScale.d.ts.map +1 -1
- package/dist/theme/expandColorScale.js +63 -33
- package/dist/theme/mergeComponents.d.ts +20 -0
- package/dist/theme/mergeComponents.d.ts.map +1 -0
- package/dist/theme/mergeComponents.js +56 -0
- package/dist/theme/onMediaTokens.d.ts +6 -1
- package/dist/theme/onMediaTokens.d.ts.map +1 -1
- package/dist/theme/onMediaTokens.js +11 -3
- package/dist/utils/characters.d.ts +27 -0
- package/dist/utils/characters.d.ts.map +1 -0
- package/dist/utils/characters.js +83 -0
- package/dist/utils/ime.d.ts +5 -0
- package/dist/utils/ime.d.ts.map +1 -0
- package/dist/utils/ime.js +63 -0
- package/dist/utils/index.d.ts +2 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +3 -1
- package/locales/en.json +48 -0
- package/locales/pseudo.json +36 -0
- package/package.json +9 -8
- package/src/Avatar/Avatar.test.tsx +6 -1
- package/src/Avatar/Avatar.tsx +3 -21
- package/src/Banner/Banner.doc.mjs +12 -10
- package/src/Banner/Banner.test.tsx +108 -0
- package/src/Banner/Banner.tsx +124 -38
- package/src/Banner/index.ts +15 -13
- package/src/BottomSheet/BottomSheet.doc.mjs +266 -0
- package/src/BottomSheet/BottomSheet.test.tsx +2114 -0
- package/src/BottomSheet/BottomSheet.tsx +545 -0
- package/src/BottomSheet/BottomSheetPanel.test.tsx +240 -0
- package/src/BottomSheet/BottomSheetPanel.tsx +547 -0
- package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +219 -0
- package/src/BottomSheet/BottomSheetSwitcher.test.tsx +850 -0
- package/src/BottomSheet/BottomSheetSwitcher.tsx +628 -0
- package/src/BottomSheet/BottomSheetSwitcherContext.ts +51 -0
- package/src/BottomSheet/index.ts +15 -0
- package/src/BottomSheet/snapOffsets.test.ts +187 -0
- package/src/BottomSheet/snapOffsets.ts +159 -0
- package/src/BottomSheet/useMobileKeyboard.ts +499 -0
- package/src/BottomSheet/useSheetGestures.test.ts +1014 -0
- package/src/BottomSheet/useSheetGestures.ts +1407 -0
- package/src/Chat/ChatComposerInput.tsx +3 -4
- package/src/Chat/ChatTokenizedText.tsx +1 -1
- package/src/ComplexSelector/ComplexSelector.doc.mjs +55 -6
- package/src/ComplexSelector/ComplexSelector.test.tsx +197 -6
- package/src/ComplexSelector/ComplexSelector.tsx +156 -28
- package/src/ComplexSelector/index.ts +3 -1
- package/src/ContextMenu/ContextMenu.tsx +4 -2
- package/src/DateInput/DateInput.test.tsx +21 -0
- package/src/DateInput/DateInput.tsx +9 -1
- package/src/DateTimeInput/DateTimeInput.doc.mjs +4 -0
- package/src/DateTimeInput/DateTimeInput.test.tsx +137 -0
- package/src/DateTimeInput/DateTimeInput.tsx +39 -2
- package/src/Dialog/Dialog.tsx +2 -1
- package/src/FileInput/FileInput.test.tsx +53 -9
- package/src/Lightbox/Lightbox.test.tsx +49 -28
- package/src/Lightbox/Lightbox.tsx +7 -3
- package/src/Markdown/parser.test.ts +53 -0
- package/src/Markdown/parser.ts +53 -12
- package/src/MobileNav/MobileNav.tsx +128 -37
- package/src/MobileNav/MobileNavCloseEdgeCases.test.tsx +426 -0
- package/src/MobileNav/MobileNavCloseTiming.test.ts +58 -0
- package/src/MobileNav/MobileNavCloseVisibility.test.tsx +172 -0
- package/src/MultiSelector/MultiSelector.test.tsx +195 -51
- package/src/MultiSelector/MultiSelector.tsx +22 -8
- package/src/NumberInput/NumberInput.test.tsx +18 -0
- package/src/NumberInput/NumberInput.tsx +9 -1
- package/src/PowerSearch/PowerSearch.test.tsx +48 -3
- package/src/PowerSearch/PowerSearch.tsx +4 -1
- package/src/PowerSearch/PowerSearchEditPopover.tsx +1 -1
- package/src/PowerSearch/formatFilterValue.test.ts +22 -0
- package/src/PowerSearch/formatFilterValue.ts +2 -4
- package/src/Selector/Selector.test.tsx +85 -26
- package/src/Selector/Selector.tsx +13 -4
- package/src/Switch/Switch.test.tsx +16 -0
- package/src/Switch/Switch.tsx +7 -0
- package/src/Table/Table.test.tsx +6 -0
- package/src/Table/columnUtils.ts +3 -1
- package/src/TextArea/TextArea.doc.mjs +1 -1
- package/src/TextArea/TextArea.test.tsx +72 -0
- package/src/TextArea/TextArea.tsx +26 -8
- package/src/TimeInput/TimeInput.test.tsx +23 -0
- package/src/TimeInput/TimeInput.tsx +8 -0
- package/src/Tokenizer/Tokenizer.test.tsx +50 -32
- package/src/Tokenizer/Tokenizer.tsx +5 -5
- package/src/Tooltip/useTooltip.tsx +4 -1
- package/src/TreeList/TreeList.doc.mjs +2 -2
- package/src/TreeList/TreeList.tsx +1 -1
- package/src/Typeahead/BaseTypeahead.tsx +10 -3
- package/src/Typeahead/Typeahead.test.tsx +116 -7
- package/src/Typeahead/Typeahead.tsx +10 -1
- package/src/__tests__/stubMatchMedia.test.ts +79 -0
- package/src/__tests__/stubMatchMedia.ts +95 -0
- package/src/hooks/index.ts +9 -5
- package/src/hooks/useFocusTrap.ts +1 -14
- package/src/hooks/useInputStatusIcon.test.tsx +19 -0
- package/src/hooks/useInputStatusIcon.tsx +7 -0
- package/src/index.ts +1 -0
- package/src/theme/defineTheme.test.ts +127 -0
- package/src/theme/defineTheme.ts +70 -55
- package/src/theme/expandColorScale.test.ts +138 -0
- package/src/theme/expandColorScale.ts +89 -44
- package/src/theme/extensibleAxes.test.ts +365 -0
- package/src/theme/mergeComponents.ts +59 -0
- package/src/theme/onMediaTokens.ts +9 -2
- package/src/utils/characters.test.ts +141 -0
- package/src/utils/characters.ts +88 -0
- package/src/utils/ime.test.ts +32 -0
- package/src/utils/ime.ts +69 -0
- package/src/utils/index.ts +4 -0
- package/dist/astryx.umd.js +0 -147
- package/dist/astryx.umd.js.map +0 -7
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* @output Token overrides for derivable color tokens
|
|
7
7
|
* @position Theme utility; consumed by defineTheme.ts
|
|
8
8
|
*
|
|
9
|
-
* Generates color token overrides from
|
|
10
|
-
*
|
|
9
|
+
* Generates color token overrides from an accent seed using the HCT
|
|
10
|
+
* perceptual color model. Only produces tokens that meaningfully
|
|
11
11
|
* derive from the accent — status colors, categorical hues, and fixed
|
|
12
12
|
* tokens (on-dark/on-light) fall through to colorDefaults.
|
|
13
13
|
*
|
|
@@ -15,11 +15,19 @@
|
|
|
15
15
|
* ramp (seeded from the default accent's hue) while the accent tokens
|
|
16
16
|
* themselves fall through to colorDefaults, same as the tokens above.
|
|
17
17
|
*
|
|
18
|
+
* `accent` also accepts a `[light, dark]` tuple (matching `TokenValue` in
|
|
19
|
+
* defineTheme). A tuple seeds each color scheme from its own half: the
|
|
20
|
+
* light side of every generated `light-dark()` pair derives from the light
|
|
21
|
+
* accent's palettes, the dark side from the dark accent's. A plain string
|
|
22
|
+
* seeds both sides identically, token for token the same output as before
|
|
23
|
+
* tuples existed.
|
|
24
|
+
*
|
|
18
25
|
* WCAG contrast guarantees (asserted in expandColorScale.test.ts):
|
|
19
26
|
* - Text tones are guaranteed >= 4.5:1 against their surfaces by tone
|
|
20
27
|
* spacing alone — HCT tone is CIE L*, which fixes relative luminance
|
|
21
28
|
* regardless of hue/chroma, so the fixed tone assignments hold for any
|
|
22
|
-
* accent/neutralStyle (WCAG 1.4.3)
|
|
29
|
+
* accent/neutralStyle (WCAG 1.4.3), and each half of a tuple pairs with
|
|
30
|
+
* surfaces derived from that same seed.
|
|
23
31
|
* - --color-border-emphasized (form-control boundaries) is tone-bumped
|
|
24
32
|
* until it reaches >= 3:1 against the generated surface (WCAG 1.4.11).
|
|
25
33
|
* - --color-border, --color-skeleton, and --color-track are intentionally
|
|
@@ -46,6 +54,9 @@ import {hexToHct, hctToHex, tonalPalette, hexWithAlpha} from './hct';
|
|
|
46
54
|
* // Minimal — just a seed color
|
|
47
55
|
* { accent: '#0064E0' }
|
|
48
56
|
*
|
|
57
|
+
* // Per-scheme seeds: light palettes from '#0064E0', dark from '#48CAE4'
|
|
58
|
+
* { accent: ['#0064E0', '#48CAE4'] }
|
|
59
|
+
*
|
|
49
60
|
* // With customization
|
|
50
61
|
* { accent: '#B7410E', neutralStyle: 'warm', contrast: 'high' }
|
|
51
62
|
*
|
|
@@ -55,14 +66,18 @@ import {hexToHct, hctToHex, tonalPalette, hexWithAlpha} from './hct';
|
|
|
55
66
|
*/
|
|
56
67
|
export interface ColorScaleConfig {
|
|
57
68
|
/**
|
|
58
|
-
* Seed accent color
|
|
69
|
+
* Seed accent color. Everything derives from this.
|
|
70
|
+
*
|
|
71
|
+
* Either a single hex (#RRGGBB) used for both color schemes, or a
|
|
72
|
+
* `[light, dark]` hex tuple: the light scheme's palettes derive from
|
|
73
|
+
* the light seed and the dark scheme's palettes from the dark seed.
|
|
59
74
|
*
|
|
60
75
|
* Optional. When omitted, the neutral palettes are seeded from the
|
|
61
76
|
* default accent's hue and the accent tokens (--color-accent,
|
|
62
77
|
* --color-accent-muted, --color-on-accent) are not generated — they
|
|
63
78
|
* fall through to colorDefaults.
|
|
64
79
|
*/
|
|
65
|
-
accent?: string;
|
|
80
|
+
accent?: string | [light: string, dark: string];
|
|
66
81
|
|
|
67
82
|
/**
|
|
68
83
|
* Neutral tone warmth. Controls how much of the seed's hue bleeds
|
|
@@ -159,6 +174,11 @@ export function ensureContrastTone(
|
|
|
159
174
|
* --color-on-dark/on-light) are NOT generated — they fall through
|
|
160
175
|
* to colorDefaults.
|
|
161
176
|
*
|
|
177
|
+
* A `[light, dark]` tuple accent seeds each scheme separately: the light
|
|
178
|
+
* half of every generated `light-dark()` pair comes from the light seed's
|
|
179
|
+
* palettes, the dark half from the dark seed's. A string accent seeds both
|
|
180
|
+
* halves from the same palettes, exactly as before tuples were supported.
|
|
181
|
+
*
|
|
162
182
|
* Without an `accent`, the accent tokens join that fall-through set: the
|
|
163
183
|
* neutrals are seeded from the default accent's hue, and --color-accent,
|
|
164
184
|
* --color-accent-muted and --color-on-accent keep their colorDefaults values.
|
|
@@ -168,6 +188,9 @@ export function ensureContrastTone(
|
|
|
168
188
|
* const tokens = expandColorScale({ accent: '#0064E0' });
|
|
169
189
|
* // tokens['--color-accent'] === 'light-dark(#..., #...)'
|
|
170
190
|
*
|
|
191
|
+
* const perScheme = expandColorScale({ accent: ['#0064E0', '#48CAE4'] });
|
|
192
|
+
* // light half derives from #0064E0, dark half from #48CAE4
|
|
193
|
+
*
|
|
171
194
|
* const neutralOnly = expandColorScale({ neutralStyle: 'warm' });
|
|
172
195
|
* // neutralOnly['--color-accent'] === undefined
|
|
173
196
|
* ```
|
|
@@ -175,16 +198,33 @@ export function ensureContrastTone(
|
|
|
175
198
|
export function expandColorScale(config: ColorScaleConfig): ColorScaleTokens {
|
|
176
199
|
const {accent, neutralStyle = 'cool', contrast = 'standard'} = config;
|
|
177
200
|
|
|
178
|
-
|
|
179
|
-
|
|
201
|
+
// Normalize to per-scheme seeds. A string accent (or an absent one) uses
|
|
202
|
+
// the same seed for both halves, which keeps single-seed output identical
|
|
203
|
+
// to the pre-tuple implementation.
|
|
204
|
+
const [lightAccent, darkAccent] = Array.isArray(accent)
|
|
205
|
+
? accent
|
|
206
|
+
: [accent, accent];
|
|
207
|
+
|
|
208
|
+
const lightSeed = hexToHct(lightAccent ?? DEFAULT_ACCENT_SEED);
|
|
209
|
+
const sameSeed = darkAccent === lightAccent;
|
|
210
|
+
const darkSeed = sameSeed
|
|
211
|
+
? lightSeed
|
|
212
|
+
: hexToHct(darkAccent ?? DEFAULT_ACCENT_SEED);
|
|
180
213
|
|
|
181
|
-
const primaryChroma = Math.max(seed.chroma, 48);
|
|
182
214
|
const neutralChroma = NEUTRAL_CHROMA[neutralStyle] ?? 5;
|
|
183
215
|
const neutralVariantChroma = NEUTRAL_VARIANT_CHROMA[neutralStyle] ?? 8;
|
|
184
216
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
217
|
+
// Palette naming: *L palettes feed the light (first) half of each ld()
|
|
218
|
+
// pair below, *D palettes the dark (second) half. With a single seed the
|
|
219
|
+
// D palettes alias the L ones.
|
|
220
|
+
const PL = tonalPalette(lightSeed.hue, Math.max(lightSeed.chroma, 48));
|
|
221
|
+
const NL = tonalPalette(lightSeed.hue, neutralChroma);
|
|
222
|
+
const NVL = tonalPalette(lightSeed.hue, neutralVariantChroma);
|
|
223
|
+
const PD = sameSeed
|
|
224
|
+
? PL
|
|
225
|
+
: tonalPalette(darkSeed.hue, Math.max(darkSeed.chroma, 48));
|
|
226
|
+
const ND = sameSeed ? NL : tonalPalette(darkSeed.hue, neutralChroma);
|
|
227
|
+
const NVD = sameSeed ? NVL : tonalPalette(darkSeed.hue, neutralVariantChroma);
|
|
188
228
|
|
|
189
229
|
const isHigh = contrast === 'high';
|
|
190
230
|
|
|
@@ -209,19 +249,19 @@ export function expandColorScale(config: ColorScaleConfig): ColorScaleTokens {
|
|
|
209
249
|
const borderEmphasizedStartDark = isHigh ? 50 : 30;
|
|
210
250
|
const borderEmphasized = ld(
|
|
211
251
|
ensureContrastTone(
|
|
212
|
-
|
|
252
|
+
lightSeed.hue,
|
|
213
253
|
neutralVariantChroma,
|
|
214
254
|
borderEmphasizedStartLight,
|
|
215
255
|
-1,
|
|
216
|
-
|
|
256
|
+
NL[99],
|
|
217
257
|
NON_TEXT_MIN_CONTRAST,
|
|
218
258
|
),
|
|
219
259
|
ensureContrastTone(
|
|
220
|
-
|
|
260
|
+
darkSeed.hue,
|
|
221
261
|
neutralVariantChroma,
|
|
222
262
|
borderEmphasizedStartDark,
|
|
223
263
|
1,
|
|
224
|
-
|
|
264
|
+
ND[10],
|
|
225
265
|
NON_TEXT_MIN_CONTRAST,
|
|
226
266
|
),
|
|
227
267
|
);
|
|
@@ -234,7 +274,7 @@ export function expandColorScale(config: ColorScaleConfig): ColorScaleTokens {
|
|
|
234
274
|
// seed above, so a supplied-but-malformed accent keeps its old behavior.
|
|
235
275
|
...(accent != null
|
|
236
276
|
? {
|
|
237
|
-
'--color-accent': ld(
|
|
277
|
+
'--color-accent': ld(PL[40], PD[80]),
|
|
238
278
|
// Derived accent tokens reference --color-accent instead of baking its
|
|
239
279
|
// resolved hex, so a scoped override of the base token re-accents the
|
|
240
280
|
// whole subtree at runtime. --color-on-accent stays baked: it is a
|
|
@@ -243,65 +283,70 @@ export function expandColorScale(config: ColorScaleConfig): ColorScaleTokens {
|
|
|
243
283
|
accentWithAlpha(0.2),
|
|
244
284
|
accentWithAlpha(0.25),
|
|
245
285
|
),
|
|
246
|
-
'--color-on-accent': ld(
|
|
286
|
+
'--color-on-accent': ld(PL[100], PD[20]),
|
|
247
287
|
}
|
|
248
288
|
: null),
|
|
249
|
-
'--color-neutral': ld(hexWithAlpha(
|
|
250
|
-
'--color-background-surface': ld(
|
|
251
|
-
'--color-background-body': ld(
|
|
252
|
-
'--color-overlay': ld(hexWithAlpha(
|
|
289
|
+
'--color-neutral': ld(hexWithAlpha(NL[10], 0.1), hexWithAlpha(ND[90], 0.2)),
|
|
290
|
+
'--color-background-surface': ld(NL[99], ND[10]),
|
|
291
|
+
'--color-background-body': ld(NL[95], ND[5]),
|
|
292
|
+
'--color-overlay': ld(hexWithAlpha(NL[10], 0.4), hexWithAlpha(ND[10], 0.6)),
|
|
253
293
|
'--color-overlay-hover': ld(
|
|
254
|
-
hexWithAlpha(
|
|
255
|
-
hexWithAlpha(
|
|
294
|
+
hexWithAlpha(NL[10], 0.05),
|
|
295
|
+
hexWithAlpha(ND[100], 0.05),
|
|
256
296
|
),
|
|
257
297
|
'--color-overlay-pressed': ld(
|
|
258
|
-
hexWithAlpha(
|
|
259
|
-
hexWithAlpha(
|
|
298
|
+
hexWithAlpha(NL[10], 0.1),
|
|
299
|
+
hexWithAlpha(ND[100], 0.1),
|
|
260
300
|
),
|
|
261
301
|
'--color-background-muted': ld(
|
|
262
|
-
hexWithAlpha(
|
|
263
|
-
hexWithAlpha(
|
|
302
|
+
hexWithAlpha(NL[10], 0.05),
|
|
303
|
+
hexWithAlpha(ND[10], 0.5),
|
|
264
304
|
),
|
|
265
305
|
|
|
266
306
|
// Text
|
|
267
|
-
'--color-text-primary': ld(
|
|
307
|
+
'--color-text-primary': ld(
|
|
308
|
+
NL[textPrimaryLightTone],
|
|
309
|
+
ND[textPrimaryDarkTone],
|
|
310
|
+
),
|
|
268
311
|
'--color-text-secondary': ld(
|
|
269
|
-
|
|
270
|
-
|
|
312
|
+
NVL[textSecondaryLightTone],
|
|
313
|
+
NVD[textSecondaryDarkTone],
|
|
271
314
|
),
|
|
272
|
-
'--color-text-disabled': ld(
|
|
315
|
+
'--color-text-disabled': ld(NVL[60], NVD[40]),
|
|
273
316
|
'--color-text-accent': 'var(--color-accent)',
|
|
274
317
|
|
|
275
318
|
// Icon
|
|
276
319
|
'--color-icon-accent': 'var(--color-accent)',
|
|
277
|
-
'--color-icon-primary': ld(
|
|
320
|
+
'--color-icon-primary': ld(
|
|
321
|
+
NL[textPrimaryLightTone],
|
|
322
|
+
ND[textPrimaryDarkTone],
|
|
323
|
+
),
|
|
278
324
|
'--color-icon-secondary': ld(
|
|
279
|
-
|
|
280
|
-
|
|
325
|
+
NVL[textSecondaryLightTone],
|
|
326
|
+
NVD[textSecondaryDarkTone],
|
|
281
327
|
),
|
|
282
|
-
'--color-icon-disabled': ld(
|
|
328
|
+
'--color-icon-disabled': ld(NVL[60], NVD[40]),
|
|
283
329
|
|
|
284
330
|
// Surface variants
|
|
285
|
-
'--color-background-card': ld(
|
|
286
|
-
'--color-background-popover': ld(
|
|
287
|
-
'--color-background-inverted': ld(
|
|
331
|
+
'--color-background-card': ld(NL[99], ND[10]),
|
|
332
|
+
'--color-background-popover': ld(NL[99], ND[20]),
|
|
333
|
+
'--color-background-inverted': ld(NL[10], ND[99]),
|
|
288
334
|
|
|
289
|
-
// Border
|
|
290
335
|
// Border
|
|
291
336
|
// Decorative hairline — not a WCAG 1.4.11 boundary. High contrast
|
|
292
337
|
// doubles the alpha so structural boundaries stay perceivable.
|
|
293
338
|
'--color-border': ld(
|
|
294
|
-
hexWithAlpha(
|
|
295
|
-
hexWithAlpha(
|
|
339
|
+
hexWithAlpha(NL[10], borderSubtleAlpha),
|
|
340
|
+
hexWithAlpha(ND[95], borderSubtleAlpha),
|
|
296
341
|
),
|
|
297
342
|
'--color-border-emphasized': borderEmphasized,
|
|
298
343
|
|
|
299
344
|
// Effects
|
|
300
|
-
'--color-skeleton': ld(
|
|
345
|
+
'--color-skeleton': ld(NVL[70], NVD[30]),
|
|
301
346
|
// Channel-on-body surface (ProgressBar/Slider tracks, Switch off-state).
|
|
302
347
|
// Defaults to the same NV[70]/NV[30] ramp stop as --color-skeleton.
|
|
303
|
-
'--color-track': ld(
|
|
304
|
-
'--color-shadow': ld(hexWithAlpha(
|
|
348
|
+
'--color-track': ld(NVL[70], NVD[30]),
|
|
349
|
+
'--color-shadow': ld(hexWithAlpha(NL[0], 0.1), hexWithAlpha(ND[0], 0.3)),
|
|
305
350
|
'--color-tint-hover': ld('black', 'white'),
|
|
306
351
|
};
|
|
307
352
|
}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/* eslint-disable @typescript-eslint/no-require-imports */
|
|
4
|
+
/**
|
|
5
|
+
* @file Guards the EXTENSIBLE prop axes — the `*Map` interfaces a theme
|
|
6
|
+
* augments — against the theming surface that has to carry them.
|
|
7
|
+
* @input Component sources (*.tsx/*.ts) and their `{Name}.doc.mjs` files.
|
|
8
|
+
* @output Vitest failures naming each map whose axis a theme cannot reach.
|
|
9
|
+
* @position Third sibling of derivedVarRegistry.test.ts (`vars`, `derived`) and
|
|
10
|
+
* themingTargets.test.ts (`targets`). Those two check what a component
|
|
11
|
+
* RENDERS against what it DOCUMENTS. Neither one looks at the open prop
|
|
12
|
+
* unions, so nothing did.
|
|
13
|
+
*
|
|
14
|
+
* An extensible axis is a promise made in three places at once, and it is only
|
|
15
|
+
* kept if all three agree:
|
|
16
|
+
*
|
|
17
|
+
* 1. `export interface FooVariantMap` in `Foo/index.ts` — the augmentation
|
|
18
|
+
* point. A consumer writes `declare module '@astryxdesign/core/Foo'`
|
|
19
|
+
* against THAT subpath, and `astryx theme build` looks for the literal
|
|
20
|
+
* interface there when it emits `<theme>.variants.d.ts`. A map declared
|
|
21
|
+
* in a sibling file and only re-exported is invisible to both.
|
|
22
|
+
* 2. `themeProps('foo', {variant})` — the axis reaching the DOM. Without it
|
|
23
|
+
* a custom variant renders no selector, so there is nothing to style.
|
|
24
|
+
* 3. `visualProps: ['variant']` on the doc's theming target — discovery, and
|
|
25
|
+
* the reason `theme build` does not reject `'foo': {'variant:custom': …}`
|
|
26
|
+
* as an unknown prop.
|
|
27
|
+
*
|
|
28
|
+
* Miss (2) and the type says yes while the CSS says nothing: TreeList shipped
|
|
29
|
+
* `TreeListVariantMap`, with a module-augmentation example in its own JSDoc,
|
|
30
|
+
* while `themeProps('tree-list', {density})` never passed `variant` — so an
|
|
31
|
+
* augmented variant type-checked, rendered, and could not be themed.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import {describe, it, expect} from 'vitest';
|
|
35
|
+
import {readdirSync, readFileSync, existsSync} from 'node:fs';
|
|
36
|
+
import {join, relative} from 'node:path';
|
|
37
|
+
import ts from 'typescript';
|
|
38
|
+
import {stableClassName} from '../naming';
|
|
39
|
+
|
|
40
|
+
const SRC_DIR = join(__dirname, '..');
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Source scanning
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* An extensible axis, keyed by the map that owns it.
|
|
48
|
+
*
|
|
49
|
+
* The OWNER is the component whose index declares the interface — that is the
|
|
50
|
+
* subpath a consumer augments and the component that has to reflect the prop.
|
|
51
|
+
* Other components may declare a prop of the same type (`AlertDialog`'s
|
|
52
|
+
* `actionVariant: ButtonVariant`, every field's `statusVariant`); they forward
|
|
53
|
+
* the value to the owner and are not separately accountable for it.
|
|
54
|
+
*/
|
|
55
|
+
interface ExtensibleAxis {
|
|
56
|
+
/** Component directory under src/ whose index declares the map. */
|
|
57
|
+
dir: string;
|
|
58
|
+
/** Interface name, e.g. 'TreeListVariantMap'. */
|
|
59
|
+
mapName: string;
|
|
60
|
+
/** The prop it types, read off the interface name, e.g. 'variant'. */
|
|
61
|
+
prop: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sourceFilesUnder(dir: string, out: string[] = []): string[] {
|
|
65
|
+
for (const entry of readdirSync(dir, {withFileTypes: true})) {
|
|
66
|
+
const full = join(dir, entry.name);
|
|
67
|
+
if (entry.isDirectory()) {
|
|
68
|
+
if (entry.name === 'node_modules' || entry.name === '__tests__') {continue;}
|
|
69
|
+
sourceFilesUnder(full, out);
|
|
70
|
+
} else if (/\.tsx?$/.test(entry.name) && !entry.name.includes('.test.')) {
|
|
71
|
+
out.push(full);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parse(file: string): ts.SourceFile {
|
|
78
|
+
return ts.createSourceFile(
|
|
79
|
+
file,
|
|
80
|
+
readFileSync(file, 'utf8'),
|
|
81
|
+
ts.ScriptTarget.Latest,
|
|
82
|
+
true,
|
|
83
|
+
file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Every `type Alias = keyof SomethingMap` in the tree, as alias -> map name.
|
|
89
|
+
* This is the shape that makes a union OPEN: augmenting the interface widens
|
|
90
|
+
* the alias, which is the whole mechanism a theme package uses.
|
|
91
|
+
*/
|
|
92
|
+
function collectKeyofAliases(files: string[]): Map<string, string> {
|
|
93
|
+
const aliases = new Map<string, string>();
|
|
94
|
+
|
|
95
|
+
/** `keyof FooMap`, and the `keyof FooMap & string` narrowing form. */
|
|
96
|
+
const mapBehind = (type: ts.TypeNode): string | null => {
|
|
97
|
+
if (ts.isIntersectionTypeNode(type)) {
|
|
98
|
+
for (const member of type.types) {
|
|
99
|
+
const found = mapBehind(member);
|
|
100
|
+
if (found != null) {return found;}
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
if (
|
|
105
|
+
ts.isTypeOperatorNode(type) &&
|
|
106
|
+
type.operator === ts.SyntaxKind.KeyOfKeyword &&
|
|
107
|
+
ts.isTypeReferenceNode(type.type) &&
|
|
108
|
+
ts.isIdentifier(type.type.typeName) &&
|
|
109
|
+
type.type.typeName.text.endsWith('Map')
|
|
110
|
+
) {
|
|
111
|
+
return type.type.typeName.text;
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
for (const file of files) {
|
|
117
|
+
const visit = (node: ts.Node): void => {
|
|
118
|
+
if (ts.isTypeAliasDeclaration(node)) {
|
|
119
|
+
const map = mapBehind(node.type);
|
|
120
|
+
if (map != null) {aliases.set(node.name.text, map);}
|
|
121
|
+
}
|
|
122
|
+
ts.forEachChild(node, visit);
|
|
123
|
+
};
|
|
124
|
+
visit(parse(file));
|
|
125
|
+
}
|
|
126
|
+
return aliases;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Maps whose alias types a prop on some `*Props` interface — i.e. the open
|
|
131
|
+
* union is a COMPONENT PROP, which is what makes it a theming axis.
|
|
132
|
+
*
|
|
133
|
+
* This is the line between the two kinds of augmentable map in the tree.
|
|
134
|
+
* `ButtonVariantMap` widens `<Button variant>`, a visual prop that reaches the
|
|
135
|
+
* DOM and gets styled. `IndicatorMap` widens the set of registered indicator
|
|
136
|
+
* NAMES — a different extension mechanism (the theme's `indicators` field,
|
|
137
|
+
* swapping a React component), with no selector and no visual prop. Holding
|
|
138
|
+
* the second to the theming contract below would be a category error.
|
|
139
|
+
*/
|
|
140
|
+
function mapsTypingAProp(
|
|
141
|
+
files: string[],
|
|
142
|
+
aliases: Map<string, string>,
|
|
143
|
+
): Set<string> {
|
|
144
|
+
const used = new Set<string>();
|
|
145
|
+
for (const file of files) {
|
|
146
|
+
const visit = (node: ts.Node): void => {
|
|
147
|
+
if (ts.isInterfaceDeclaration(node) && node.name.text.endsWith('Props')) {
|
|
148
|
+
for (const member of node.members) {
|
|
149
|
+
if (!ts.isPropertySignature(member) || member.type == null) {continue;}
|
|
150
|
+
const typeNode = ts.isArrayTypeNode(member.type)
|
|
151
|
+
? member.type.elementType
|
|
152
|
+
: member.type;
|
|
153
|
+
if (
|
|
154
|
+
ts.isTypeReferenceNode(typeNode) &&
|
|
155
|
+
ts.isIdentifier(typeNode.typeName)
|
|
156
|
+
) {
|
|
157
|
+
const map = aliases.get(typeNode.typeName.text);
|
|
158
|
+
if (map != null) {used.add(map);}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
ts.forEachChild(node, visit);
|
|
163
|
+
};
|
|
164
|
+
visit(parse(file));
|
|
165
|
+
}
|
|
166
|
+
return used;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Every map that is (a) an open union — some `type X = keyof <Map>` exists, so
|
|
171
|
+
* augmenting it widens a real type — and (b) declared in a component index.
|
|
172
|
+
*
|
|
173
|
+
* The prop name is read off the interface, which is the same convention
|
|
174
|
+
* `astryx theme build` uses to find an augmentation point:
|
|
175
|
+
* `<Prefix><Prop>Map`. `TextColorMap` -> Text, `color`.
|
|
176
|
+
* `AvatarStatusDotVariantMap` -> Avatar, `variant`.
|
|
177
|
+
*/
|
|
178
|
+
function collectExtensibleAxes(
|
|
179
|
+
files: string[],
|
|
180
|
+
aliases: Map<string, string>,
|
|
181
|
+
propMaps: Set<string>,
|
|
182
|
+
): ExtensibleAxis[] {
|
|
183
|
+
const open = propMaps;
|
|
184
|
+
const axes: ExtensibleAxis[] = [];
|
|
185
|
+
for (const file of files) {
|
|
186
|
+
if (!/\/index\.tsx?$/.test(file)) {continue;}
|
|
187
|
+
const dir = relative(SRC_DIR, file).split('/')[0];
|
|
188
|
+
const visit = (node: ts.Node): void => {
|
|
189
|
+
if (
|
|
190
|
+
ts.isInterfaceDeclaration(node) &&
|
|
191
|
+
node.name.text.endsWith('Map') &&
|
|
192
|
+
open.has(node.name.text)
|
|
193
|
+
) {
|
|
194
|
+
const bare = node.name.text.slice(0, -'Map'.length);
|
|
195
|
+
// The trailing PascalCase word is the prop.
|
|
196
|
+
const match = /([A-Z][a-z0-9]*)$/.exec(bare);
|
|
197
|
+
if (match == null) {return;}
|
|
198
|
+
const prop = match[1].charAt(0).toLowerCase() + match[1].slice(1);
|
|
199
|
+
axes.push({dir, mapName: node.name.text, prop});
|
|
200
|
+
}
|
|
201
|
+
ts.forEachChild(node, visit);
|
|
202
|
+
};
|
|
203
|
+
visit(parse(file));
|
|
204
|
+
}
|
|
205
|
+
return axes;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Every `themeProps('name', {...})` site, as class -> the prop keys it passes. */
|
|
209
|
+
function collectThemePropsSites(files: string[]): Map<string, Set<string>> {
|
|
210
|
+
const sites = new Map<string, Set<string>>();
|
|
211
|
+
for (const file of files) {
|
|
212
|
+
const visit = (node: ts.Node): void => {
|
|
213
|
+
if (
|
|
214
|
+
ts.isCallExpression(node) &&
|
|
215
|
+
ts.isIdentifier(node.expression) &&
|
|
216
|
+
node.expression.text === 'themeProps'
|
|
217
|
+
) {
|
|
218
|
+
const [nameArg, propsArg] = node.arguments;
|
|
219
|
+
if (nameArg != null && ts.isStringLiteralLike(nameArg)) {
|
|
220
|
+
const cls = stableClassName(nameArg.text);
|
|
221
|
+
const keys = sites.get(cls) ?? new Set<string>();
|
|
222
|
+
if (propsArg != null && ts.isObjectLiteralExpression(propsArg)) {
|
|
223
|
+
for (const prop of propsArg.properties) {
|
|
224
|
+
const name = prop.name;
|
|
225
|
+
if (
|
|
226
|
+
name != null &&
|
|
227
|
+
(ts.isIdentifier(name) || ts.isStringLiteralLike(name))
|
|
228
|
+
) {
|
|
229
|
+
keys.add(name.text);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
sites.set(cls, keys);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
ts.forEachChild(node, visit);
|
|
237
|
+
};
|
|
238
|
+
visit(parse(file));
|
|
239
|
+
}
|
|
240
|
+
return sites;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// Docs
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
interface DocTarget {
|
|
248
|
+
className: string;
|
|
249
|
+
visualProps: string[];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function collectDocTargets(dir: string): Map<string, DocTarget[]> {
|
|
253
|
+
const byDir = new Map<string, DocTarget[]>();
|
|
254
|
+
const walk = (current: string): void => {
|
|
255
|
+
for (const entry of readdirSync(current, {withFileTypes: true})) {
|
|
256
|
+
const full = join(current, entry.name);
|
|
257
|
+
if (entry.isDirectory()) {
|
|
258
|
+
if (entry.name === 'node_modules' || entry.name === '__tests__') {
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
walk(full);
|
|
262
|
+
} else if (entry.name.endsWith('.doc.mjs')) {
|
|
263
|
+
const componentDir = relative(SRC_DIR, full).split('/')[0];
|
|
264
|
+
let doc;
|
|
265
|
+
try {
|
|
266
|
+
doc = require(full).docs;
|
|
267
|
+
} catch {
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
const targets = (doc?.theming?.targets ?? [])
|
|
271
|
+
.filter(
|
|
272
|
+
(t: unknown): t is {className: string} =>
|
|
273
|
+
typeof (t as {className?: unknown})?.className === 'string',
|
|
274
|
+
)
|
|
275
|
+
.map((t: {className: string; visualProps?: string[]}) => ({
|
|
276
|
+
className: t.className,
|
|
277
|
+
visualProps: t.visualProps ?? [],
|
|
278
|
+
}));
|
|
279
|
+
byDir.set(componentDir, [
|
|
280
|
+
...(byDir.get(componentDir) ?? []),
|
|
281
|
+
...targets,
|
|
282
|
+
]);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
walk(dir);
|
|
287
|
+
return byDir;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
const files = sourceFilesUnder(SRC_DIR);
|
|
293
|
+
const aliases = collectKeyofAliases(files);
|
|
294
|
+
const propMaps = mapsTypingAProp(files, aliases);
|
|
295
|
+
const axes = collectExtensibleAxes(files, aliases, propMaps);
|
|
296
|
+
const themePropsSites = collectThemePropsSites(files);
|
|
297
|
+
const docTargets = collectDocTargets(SRC_DIR);
|
|
298
|
+
|
|
299
|
+
/** The target a component's own name maps to, e.g. TreeList -> astryx-tree-list. */
|
|
300
|
+
function ownTargets(axis: ExtensibleAxis): DocTarget[] {
|
|
301
|
+
return docTargets.get(axis.dir) ?? [];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
describe('extensible prop axes are reachable by a theme', () => {
|
|
305
|
+
it('finds the axes at all (guards the guard)', () => {
|
|
306
|
+
// If the AST walk silently stopped matching, every assertion below would
|
|
307
|
+
// pass on an empty list.
|
|
308
|
+
expect(axes.length).toBeGreaterThan(10);
|
|
309
|
+
expect(axes.map(a => a.mapName)).toContain('ButtonVariantMap');
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('every open prop union is declared in an index a consumer can augment', () => {
|
|
313
|
+
// Module augmentation only widens the module where the interface is
|
|
314
|
+
// DECLARED, and the CLI greps the public subpath for that literal
|
|
315
|
+
// declaration. A map in a sibling file, re-exported, satisfies neither:
|
|
316
|
+
// `declare module '@astryxdesign/core/<Component>'` would create a new,
|
|
317
|
+
// unrelated interface and `theme build` would emit no augmentation.
|
|
318
|
+
// `collectExtensibleAxes` only reads indexes, so anything open and
|
|
319
|
+
// declared elsewhere is missing from `axes` entirely — compare against
|
|
320
|
+
// every open map in the tree to catch that.
|
|
321
|
+
const declaredInAnIndex = new Set(axes.map(a => a.mapName));
|
|
322
|
+
const notAugmentable = [...propMaps].filter(m => !declaredInAnIndex.has(m));
|
|
323
|
+
expect(
|
|
324
|
+
notAugmentable,
|
|
325
|
+
'these maps type an open union but are not declared in a component index, ' +
|
|
326
|
+
'so no consumer can augment them at @astryxdesign/core/<Component>',
|
|
327
|
+
).toEqual([]);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it.each(axes.map(a => [`${a.dir}.${a.prop} (${a.mapName})`, a] as const))(
|
|
331
|
+
'%s reaches the DOM through themeProps',
|
|
332
|
+
(_label, axis) => {
|
|
333
|
+
// The axis is extensible, so a consumer can add a value we have never
|
|
334
|
+
// seen. The only way their CSS can select it is if the component
|
|
335
|
+
// reflects the prop.
|
|
336
|
+
const targets = ownTargets(axis);
|
|
337
|
+
const reflected = targets.some(t =>
|
|
338
|
+
themePropsSites.get(t.className)?.has(axis.prop),
|
|
339
|
+
);
|
|
340
|
+
expect(
|
|
341
|
+
reflected,
|
|
342
|
+
`${axis.dir} lets a theme add \`${axis.prop}\` values via ${axis.mapName}, but no ` +
|
|
343
|
+
`themeProps() call passes \`${axis.prop}\` — a custom value renders no selector, ` +
|
|
344
|
+
`so it cannot be styled. Pass it: themeProps('<target>', {${axis.prop}}).`,
|
|
345
|
+
).toBe(true);
|
|
346
|
+
},
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
it.each(axes.map(a => [`${a.dir}.${a.prop} (${a.mapName})`, a] as const))(
|
|
350
|
+
'%s is documented as a visual prop',
|
|
351
|
+
(_label, axis) => {
|
|
352
|
+
// Undocumented, it is undiscoverable — and `theme build` rejects
|
|
353
|
+
// `'<target>': {'<prop>:custom': …}` as an unknown prop, because its
|
|
354
|
+
// known-prop set is built from exactly this field.
|
|
355
|
+
const targets = ownTargets(axis);
|
|
356
|
+
const documented = targets.some(t => t.visualProps.includes(axis.prop));
|
|
357
|
+
expect(
|
|
358
|
+
documented,
|
|
359
|
+
`${axis.dir} lets a theme add \`${axis.prop}\` values via ${axis.mapName}, but no ` +
|
|
360
|
+
`theming target documents \`${axis.prop}\` in visualProps — a theme author cannot ` +
|
|
361
|
+
`discover the axis, and \`astryx theme build\` warns "Unknown prop" on it.`,
|
|
362
|
+
).toBe(true);
|
|
363
|
+
},
|
|
364
|
+
);
|
|
365
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Component style-map merging
|
|
5
|
+
*
|
|
6
|
+
* One merge rule for component overrides, shared by every layer that composes
|
|
7
|
+
* them: `extends` inheritance, generated type-scale rules, and the on-media
|
|
8
|
+
* (`onDark`/`onLight`) surfaces. Merging is per style key, so a child that
|
|
9
|
+
* restates one property of `button.base` keeps the rest of the base's.
|
|
10
|
+
*
|
|
11
|
+
* @input two ComponentStyleMaps — the base and the overrides that win
|
|
12
|
+
* @output a merged ComponentStyleMap
|
|
13
|
+
* @position packages/core/src/theme/mergeComponents.ts
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type {ComponentStyleMap} from './defineTheme';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Deep-merge component style maps: `overrides` wins per style key, and every
|
|
20
|
+
* component and key the base declared that the overrides do not mention is
|
|
21
|
+
* carried through untouched.
|
|
22
|
+
*/
|
|
23
|
+
export function deepMergeComponents(
|
|
24
|
+
base?: ComponentStyleMap,
|
|
25
|
+
overrides?: ComponentStyleMap,
|
|
26
|
+
): ComponentStyleMap | undefined {
|
|
27
|
+
if (!base && !overrides) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
if (!base) {
|
|
31
|
+
return overrides;
|
|
32
|
+
}
|
|
33
|
+
if (!overrides) {
|
|
34
|
+
return base;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const result: ComponentStyleMap = {};
|
|
38
|
+
|
|
39
|
+
// Start with all base entries
|
|
40
|
+
for (const [component, rules] of Object.entries(base)) {
|
|
41
|
+
result[component] = {...rules};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Merge overrides on top
|
|
45
|
+
for (const [component, rules] of Object.entries(overrides)) {
|
|
46
|
+
if (!result[component]) {
|
|
47
|
+
result[component] = {...rules};
|
|
48
|
+
} else {
|
|
49
|
+
for (const [key, styles] of Object.entries(rules)) {
|
|
50
|
+
result[component][key] = {
|
|
51
|
+
...result[component][key],
|
|
52
|
+
...styles,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return result;
|
|
59
|
+
}
|