@wix/zero-config-implementation 1.66.0 → 1.68.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{index-BEdi2EUy.js → index-BewAGOQY.js} +13017 -12847
- package/dist/{index-CtX9f415.js → index-DTB9iwv9.js} +1 -1
- package/dist/index.d.ts +27 -0
- package/dist/index.js +1 -1
- package/package.json +2 -2
- package/src/converters/custom-states-builder.test.ts +137 -0
- package/src/converters/custom-states-builder.ts +117 -0
- package/src/converters/to-editor-component.ts +53 -3
- package/src/information-extractors/css/parse.test.ts +122 -0
- package/src/information-extractors/css/parse.ts +177 -1
- package/src/information-extractors/css/types.ts +27 -0
- package/src/information-extractors/ts/components.test.ts +29 -0
- package/src/information-extractors/ts/components.ts +72 -3
- package/src/information-extractors/ts/types.ts +5 -0
- package/src/utils/css-class.ts +10 -0
|
@@ -2,7 +2,8 @@ import { CSS_PROPERTIES } from '@wix/react-component-schema'
|
|
|
2
2
|
import { camelCase } from 'case-anything'
|
|
3
3
|
import { generate, lexer, parse, walk } from 'css-tree'
|
|
4
4
|
import type { Atrule, CssNode, Declaration, FunctionNode, Rule, Selector } from 'css-tree'
|
|
5
|
-
import
|
|
5
|
+
import { stateNameFromModifier } from '../../utils/css-class'
|
|
6
|
+
import type { CSSParserAPI, CSSProperty, NativePseudoClass, StateClassRule } from './types'
|
|
6
7
|
|
|
7
8
|
type WalkContext = {
|
|
8
9
|
atrule?: Atrule | null
|
|
@@ -20,8 +21,13 @@ function isInsideKeyframes(currentContext: WalkContext): boolean {
|
|
|
20
21
|
export function parseCss(cssString: string): CSSParserAPI {
|
|
21
22
|
const parsedSelectors = new Map<string, Selector>()
|
|
22
23
|
const { propertiesMap: allProperties, varUsagesByProperty } = parseAllProperties(cssString, parsedSelectors)
|
|
24
|
+
const stateClasses = parseStateClasses(cssString)
|
|
23
25
|
|
|
24
26
|
return {
|
|
27
|
+
getStateClasses(): StateClassRule[] {
|
|
28
|
+
return stateClasses
|
|
29
|
+
},
|
|
30
|
+
|
|
25
31
|
getPropertiesForSelector(selector: string): CSSProperty[] {
|
|
26
32
|
return allProperties.get(selector) ?? []
|
|
27
33
|
},
|
|
@@ -198,6 +204,176 @@ function extractVarNames(valueNode: CssNode): string[] {
|
|
|
198
204
|
return varNames
|
|
199
205
|
}
|
|
200
206
|
|
|
207
|
+
// Native pseudo-classes mapped to their design-state key. `:focus-visible` and
|
|
208
|
+
// `:focus-within` both collapse to `focus` (the schema has a single focus state).
|
|
209
|
+
const NATIVE_PSEUDO_BY_NAME: Record<string, NativePseudoClass> = {
|
|
210
|
+
hover: 'hover',
|
|
211
|
+
focus: 'focus',
|
|
212
|
+
'focus-visible': 'focus',
|
|
213
|
+
'focus-within': 'focus',
|
|
214
|
+
disabled: 'disabled',
|
|
215
|
+
invalid: 'invalid',
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const GLOBAL_MODIFIER_PATTERN = /^:global\(\s*\.([\w-]+)\s*\)$/
|
|
219
|
+
|
|
220
|
+
interface AnalyzedCompound {
|
|
221
|
+
/** DOM-matchable base of the compound the modifier/pseudo is attached to, e.g. `.root`. */
|
|
222
|
+
base: string
|
|
223
|
+
/** Native pseudo-class on this compound, if any. */
|
|
224
|
+
pseudoClass?: NativePseudoClass
|
|
225
|
+
/** Global modifier class names extracted from `:global(.X)` parts on this compound. */
|
|
226
|
+
modifiers: string[]
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
type SelectorPart = { kind: 'local'; selector: string } | { kind: 'global'; className: string }
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Decides whether a `:global(.X)` class on a selector is a state *modifier* (vs. the
|
|
233
|
+
* base element it modifies):
|
|
234
|
+
* - If the selector has a local (CSS-module) class, that local class is always the
|
|
235
|
+
* base, so every global class is a modifier — the current, common form
|
|
236
|
+
* (`.root:global(.toggle--hover)`).
|
|
237
|
+
* - Otherwise the element itself is authored as a global class, and the base is the
|
|
238
|
+
* BEM root: a global class is a modifier only when it `--`-extends another global
|
|
239
|
+
* class on the selector (`:global(.card):global(.card--featured)` → base `.card`,
|
|
240
|
+
* modifier `card--featured`). A lone `:global(.card):hover` keeps `.card` as base.
|
|
241
|
+
*/
|
|
242
|
+
function isModifierClass(className: string, hasLocalBase: boolean, globalClassNames: string[]): boolean {
|
|
243
|
+
if (hasLocalBase) return true
|
|
244
|
+
return globalClassNames.some((other) => other !== className && className.startsWith(`${other}--`))
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Analyzes a single compound selector (no combinators) into its DOM-matchable base,
|
|
249
|
+
* a native pseudo-class (if present), and any `:global(.modifier)` class names. Returns
|
|
250
|
+
* null for a compound carrying a pseudo-element (unmatchable) or with no base.
|
|
251
|
+
*
|
|
252
|
+
* The element a modifier is applied to may itself be authored as a global class, so
|
|
253
|
+
* the base/modifier split is resolved across all of the compound's classes rather
|
|
254
|
+
* than assuming the base is local — see `isModifierClass`.
|
|
255
|
+
*/
|
|
256
|
+
function analyzeCompound(componentNodes: CssNode[]): AnalyzedCompound | null {
|
|
257
|
+
let pseudoClass: NativePseudoClass | undefined
|
|
258
|
+
const parts: SelectorPart[] = []
|
|
259
|
+
|
|
260
|
+
for (const component of componentNodes) {
|
|
261
|
+
if (component.type === 'PseudoElementSelector') return null
|
|
262
|
+
if (component.type === 'PseudoClassSelector') {
|
|
263
|
+
if (component.name === 'global') {
|
|
264
|
+
const match = generate(component).match(GLOBAL_MODIFIER_PATTERN)
|
|
265
|
+
if (match) parts.push({ kind: 'global', className: match[1] })
|
|
266
|
+
continue
|
|
267
|
+
}
|
|
268
|
+
pseudoClass ??= NATIVE_PSEUDO_BY_NAME[component.name]
|
|
269
|
+
continue
|
|
270
|
+
}
|
|
271
|
+
parts.push({ kind: 'local', selector: generate(component) })
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const hasLocalBase = parts.some((part) => part.kind === 'local')
|
|
275
|
+
const globalClassNames = parts.flatMap((part) => (part.kind === 'global' ? [part.className] : []))
|
|
276
|
+
|
|
277
|
+
const baseParts: string[] = []
|
|
278
|
+
const modifiers: string[] = []
|
|
279
|
+
for (const part of parts) {
|
|
280
|
+
if (part.kind === 'local') {
|
|
281
|
+
baseParts.push(part.selector)
|
|
282
|
+
} else if (isModifierClass(part.className, hasLocalBase, globalClassNames)) {
|
|
283
|
+
modifiers.push(part.className)
|
|
284
|
+
} else {
|
|
285
|
+
baseParts.push(`.${part.className}`)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const base = baseParts.join('')
|
|
290
|
+
if (!base) return null
|
|
291
|
+
|
|
292
|
+
return { base, pseudoClass, modifiers }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Splits a selector into compound selectors at combinators and analyzes each. A
|
|
297
|
+
* `:global(.modifier)` (or native pseudo) belongs to the compound it is attached to,
|
|
298
|
+
* so ancestor/descendant compounds in the same selector are ignored for the base —
|
|
299
|
+
* e.g. `.header:global(.x--selected) .label` resolves to base `.header`, not
|
|
300
|
+
* `.header .label`. Returns one entry per compound that carries a modifier or pseudo.
|
|
301
|
+
*/
|
|
302
|
+
function analyzeStateSelector(selector: Selector): AnalyzedCompound[] {
|
|
303
|
+
const compounds: CssNode[][] = [[]]
|
|
304
|
+
for (const component of selector.children) {
|
|
305
|
+
if (component.type === 'Combinator') {
|
|
306
|
+
compounds.push([])
|
|
307
|
+
} else {
|
|
308
|
+
compounds[compounds.length - 1].push(component)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const results: AnalyzedCompound[] = []
|
|
313
|
+
for (const compoundNodes of compounds) {
|
|
314
|
+
const analyzed = analyzeCompound(compoundNodes)
|
|
315
|
+
if (analyzed && (analyzed.modifiers.length > 0 || analyzed.pseudoClass)) {
|
|
316
|
+
results.push(analyzed)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return results
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Walks every CSS rule and extracts `:global(.modifier)` design-state classes,
|
|
324
|
+
* pairing each modifier with any native pseudo-class found on the same base within
|
|
325
|
+
* the same rule. This realizes the contract that a selector matching both a pseudo
|
|
326
|
+
* and a global modifier marks a native state's editor-trigger class, while a global
|
|
327
|
+
* modifier with no paired pseudo marks a custom state.
|
|
328
|
+
*/
|
|
329
|
+
function parseStateClasses(cssString: string): StateClassRule[] {
|
|
330
|
+
const results: StateClassRule[] = []
|
|
331
|
+
|
|
332
|
+
try {
|
|
333
|
+
const ast = parse(cssString)
|
|
334
|
+
|
|
335
|
+
walk(ast, {
|
|
336
|
+
visit: 'Rule',
|
|
337
|
+
enter(rule: Rule) {
|
|
338
|
+
if (rule.prelude.type !== 'SelectorList') return
|
|
339
|
+
|
|
340
|
+
const pseudoByBase = new Map<string, NativePseudoClass>()
|
|
341
|
+
const modifiersByBase = new Map<string, Set<string>>()
|
|
342
|
+
|
|
343
|
+
for (const selectorNode of rule.prelude.children) {
|
|
344
|
+
if (selectorNode.type !== 'Selector') continue
|
|
345
|
+
for (const analyzed of analyzeStateSelector(selectorNode)) {
|
|
346
|
+
if (analyzed.pseudoClass && !pseudoByBase.has(analyzed.base)) {
|
|
347
|
+
pseudoByBase.set(analyzed.base, analyzed.pseudoClass)
|
|
348
|
+
}
|
|
349
|
+
if (analyzed.modifiers.length > 0) {
|
|
350
|
+
const modifiers = modifiersByBase.get(analyzed.base) ?? new Set<string>()
|
|
351
|
+
for (const modifier of analyzed.modifiers) modifiers.add(modifier)
|
|
352
|
+
modifiersByBase.set(analyzed.base, modifiers)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
for (const [baseSelector, modifiers] of modifiersByBase) {
|
|
358
|
+
const basePseudo = pseudoByBase.get(baseSelector)
|
|
359
|
+
for (const modifier of modifiers) {
|
|
360
|
+
// A modifier is the editor-trigger class for a NATIVE state only when its
|
|
361
|
+
// own state name matches the base's pseudo (`x--hover` ↔ `:hover`). A custom
|
|
362
|
+
// modifier sharing a base with a native pseudo (`x--selected` next to `:hover`)
|
|
363
|
+
// stays custom — it must not inherit the unrelated pseudo.
|
|
364
|
+
const pseudoClass = basePseudo && stateNameFromModifier(modifier) === basePseudo ? basePseudo : undefined
|
|
365
|
+
results.push({ baseSelector, modifier, pseudoClass })
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
})
|
|
370
|
+
} catch (error) {
|
|
371
|
+
console.error('CSS state-class parsing error:', error)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return results
|
|
375
|
+
}
|
|
376
|
+
|
|
201
377
|
/**
|
|
202
378
|
* Builds a DOM-queryable selector by filtering out pseudo-classes and pseudo-elements.
|
|
203
379
|
* Returns null if the selector contains pseudo-elements (unmatchable against real DOM).
|
|
@@ -15,6 +15,26 @@ export interface MatchedCssData {
|
|
|
15
15
|
customProperties: Record<string, string>
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
export type NativePseudoClass = 'hover' | 'focus' | 'disabled' | 'invalid'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A `:global(.modifier)` design-state class found on a base element selector.
|
|
22
|
+
* Produced by walking each CSS rule and pairing the global modifier with any
|
|
23
|
+
* native pseudo-class present on the same base within that rule.
|
|
24
|
+
*/
|
|
25
|
+
export interface StateClassRule {
|
|
26
|
+
/** DOM-matchable base selector (e.g. `.root`), used to map the modifier to an element. */
|
|
27
|
+
baseSelector: string
|
|
28
|
+
/** Global modifier class name from `:global(.X)`, without the leading dot. */
|
|
29
|
+
modifier: string
|
|
30
|
+
/**
|
|
31
|
+
* Native pseudo-class paired with this modifier on the same base within the same
|
|
32
|
+
* rule. When present, the modifier is the editor-trigger class for a native state;
|
|
33
|
+
* when absent, the modifier denotes a custom (non-native) design state.
|
|
34
|
+
*/
|
|
35
|
+
pseudoClass?: NativePseudoClass
|
|
36
|
+
}
|
|
37
|
+
|
|
18
38
|
/**
|
|
19
39
|
* API returned by parseCss function for querying parsed CSS
|
|
20
40
|
*/
|
|
@@ -66,4 +86,11 @@ export interface CSSParserAPI {
|
|
|
66
86
|
* @param defaultValue - The initial value string of the custom property
|
|
67
87
|
*/
|
|
68
88
|
getVarPropertyType: (varName: string, defaultValue: string) => string | undefined
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Returns every `:global(.modifier)` design-state class found in the CSS, paired
|
|
92
|
+
* with any native pseudo-class on the same base element within the same rule.
|
|
93
|
+
* Modifiers with no paired pseudo are custom (non-native) state triggers.
|
|
94
|
+
*/
|
|
95
|
+
getStateClasses: () => StateClassRule[]
|
|
69
96
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { compileTsFile } from '../../ts-compiler'
|
|
4
|
+
import { extractAllComponentInfo } from './components'
|
|
5
|
+
|
|
6
|
+
const propTriggeredFixture = path.resolve(
|
|
7
|
+
__dirname,
|
|
8
|
+
'../../../../example-components/src/components/DesignStates/Custom/PropTriggered/WithMatchingClass/component.tsx',
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
describe('TS extractor — ElementState<> trigger detection', () => {
|
|
12
|
+
it('flags an ElementState<>-wrapped prop and unwraps its inner type', async () => {
|
|
13
|
+
const programResult = await compileTsFile(propTriggeredFixture)
|
|
14
|
+
expect(programResult.isOk()).toBe(true)
|
|
15
|
+
const program = programResult._unsafeUnwrap()
|
|
16
|
+
|
|
17
|
+
const components = extractAllComponentInfo(program, propTriggeredFixture)
|
|
18
|
+
const component = components.find((candidate) => candidate.componentName === 'PropStates')
|
|
19
|
+
expect(component).toBeDefined()
|
|
20
|
+
|
|
21
|
+
const isLoading = component!.props.isLoading
|
|
22
|
+
expect(isLoading.isStateTrigger).toBe(true)
|
|
23
|
+
// The `ElementState<boolean>` wrapper is unwrapped — the prop behaves as a plain boolean.
|
|
24
|
+
expect(isLoading.type).toBe('boolean')
|
|
25
|
+
|
|
26
|
+
// Plain booleans are never flagged as state triggers.
|
|
27
|
+
expect(component!.props.showLabel.isStateTrigger).toBeUndefined()
|
|
28
|
+
})
|
|
29
|
+
})
|
|
@@ -208,16 +208,27 @@ function convertComponentDoc(doc: ComponentDoc, program: ts.Program, checker: ts
|
|
|
208
208
|
|
|
209
209
|
// Resolve the type deeply
|
|
210
210
|
let declaredTypeInfo: DeclaredTypeInfo | undefined
|
|
211
|
+
let stateTrigger: StateTriggerInfo | undefined
|
|
211
212
|
if (propSymbol) {
|
|
212
213
|
const decl = propSymbol.getDeclarations()?.[0]
|
|
213
214
|
if (decl) {
|
|
214
|
-
|
|
215
|
+
const declSourceFile = decl.getSourceFile()
|
|
216
|
+
declaredTypeInfo = getDeclaredTypeInfo(propSymbol, declSourceFile, checker)
|
|
217
|
+
stateTrigger = detectStateTrigger(propSymbol, declSourceFile, checker)
|
|
215
218
|
}
|
|
216
219
|
}
|
|
217
220
|
|
|
218
|
-
|
|
221
|
+
// An `ElementState<Inner>` prop should behave as its inner type everywhere except
|
|
222
|
+
// for the `isStateTrigger` flag, so unwrap the inner type for `type` and resolution
|
|
223
|
+
// (and drop the `ElementState` declared symbol so it isn't mistaken for a semantic type).
|
|
224
|
+
const typeString = stateTrigger?.innerTypeName ?? declaredTypeInfo?.name ?? propItem.type.name
|
|
219
225
|
const resolvedType = propType
|
|
220
|
-
? resolveType({
|
|
226
|
+
? resolveType({
|
|
227
|
+
type: propType,
|
|
228
|
+
checker,
|
|
229
|
+
typeString,
|
|
230
|
+
declaredSymbol: stateTrigger ? undefined : declaredTypeInfo?.symbol,
|
|
231
|
+
})
|
|
221
232
|
: { kind: 'primitive' as const, value: propItem.type.name }
|
|
222
233
|
|
|
223
234
|
// Convert default value
|
|
@@ -239,6 +250,7 @@ function convertComponentDoc(doc: ComponentDoc, program: ts.Program, checker: ts
|
|
|
239
250
|
resolvedType,
|
|
240
251
|
description: propItem.description || undefined,
|
|
241
252
|
deprecated: tags?.deprecated !== undefined ? true : undefined,
|
|
253
|
+
isStateTrigger: stateTrigger ? true : undefined,
|
|
242
254
|
}
|
|
243
255
|
}
|
|
244
256
|
|
|
@@ -477,6 +489,63 @@ interface DeclaredTypeInfo {
|
|
|
477
489
|
symbol?: ts.Symbol
|
|
478
490
|
}
|
|
479
491
|
|
|
492
|
+
interface StateTriggerInfo {
|
|
493
|
+
/** Text of the inner type argument, e.g. `'boolean'`. */
|
|
494
|
+
innerTypeName: string
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Detects when a prop is declared with the `ElementState<>` marker type from
|
|
499
|
+
* `@wix/react-component-utils` (e.g. `isLoading?: ElementState<boolean>`), which opts
|
|
500
|
+
* the prop in as the trigger for a custom design state. Returns the inner type
|
|
501
|
+
* argument so callers can treat the prop as its underlying type. Returns
|
|
502
|
+
* `undefined` for any other declaration.
|
|
503
|
+
*/
|
|
504
|
+
function detectStateTrigger(
|
|
505
|
+
prop: ts.Symbol,
|
|
506
|
+
sourceFile: ts.SourceFile,
|
|
507
|
+
checker: ts.TypeChecker,
|
|
508
|
+
): StateTriggerInfo | undefined {
|
|
509
|
+
const declaration = prop.getDeclarations()?.[0]
|
|
510
|
+
if (!declaration) return undefined
|
|
511
|
+
|
|
512
|
+
if (
|
|
513
|
+
!(ts.isPropertySignature(declaration) || ts.isPropertyDeclaration(declaration) || ts.isParameter(declaration)) ||
|
|
514
|
+
!declaration.type ||
|
|
515
|
+
!ts.isTypeReferenceNode(declaration.type) ||
|
|
516
|
+
!declaration.type.typeArguments ||
|
|
517
|
+
declaration.type.typeArguments.length !== 1
|
|
518
|
+
) {
|
|
519
|
+
return undefined
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const typeName = declaration.type.typeName
|
|
523
|
+
const identifier = ts.isIdentifier(typeName) ? typeName : typeName.right
|
|
524
|
+
if (identifier.text !== 'ElementState') return undefined
|
|
525
|
+
if (!isElementStateAliasFromUtils(identifier, checker)) return undefined
|
|
526
|
+
|
|
527
|
+
return { innerTypeName: declaration.type.typeArguments[0].getText(sourceFile) }
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Confirms an identifier referencing `ElementState` resolves to the marker type
|
|
532
|
+
* alias exported by `@wix/react-component-utils`, rather than an unrelated local
|
|
533
|
+
* type that happens to be named `ElementState`.
|
|
534
|
+
*/
|
|
535
|
+
function isElementStateAliasFromUtils(identifier: ts.Identifier, checker: ts.TypeChecker): boolean {
|
|
536
|
+
let symbol = checker.getSymbolAtLocation(identifier)
|
|
537
|
+
if (!symbol) return false
|
|
538
|
+
if ((symbol.getFlags() & ts.SymbolFlags.Alias) !== 0) {
|
|
539
|
+
symbol = checker.getAliasedSymbol(symbol)
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const declaration = symbol.getDeclarations()?.[0]
|
|
543
|
+
if (!declaration || !ts.isTypeAliasDeclaration(declaration)) return false
|
|
544
|
+
if (declaration.name.text !== 'ElementState') return false
|
|
545
|
+
|
|
546
|
+
return declaration.getSourceFile().fileName.includes('react-component-utils')
|
|
547
|
+
}
|
|
548
|
+
|
|
480
549
|
function getDeclaredTypeInfo(
|
|
481
550
|
prop: ts.Symbol,
|
|
482
551
|
sourceFile: ts.SourceFile,
|
|
@@ -51,6 +51,11 @@ export interface PropInfo {
|
|
|
51
51
|
description?: string
|
|
52
52
|
// Whether the prop is marked as deprecated via @deprecated JSDoc tag
|
|
53
53
|
deprecated?: boolean
|
|
54
|
+
// True when the prop is wrapped in the `ElementState<>` marker type from
|
|
55
|
+
// @wix/react-component-utils, declaring it as the trigger for a custom design
|
|
56
|
+
// state. `type` and `resolvedType` reflect the unwrapped inner type, so the prop
|
|
57
|
+
// still behaves as a normal data prop everywhere else.
|
|
58
|
+
isStateTrigger?: boolean
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
export interface ComponentInfo {
|
package/src/utils/css-class.ts
CHANGED
|
@@ -46,3 +46,13 @@ export function findPreferredSemanticClass(classNames: string[]): string | undef
|
|
|
46
46
|
const semanticClasses = classNames.filter(isGlobalSemanticClass)
|
|
47
47
|
return semanticClasses.find((className) => !HAS_BEM_MODIFIER.test(className)) ?? semanticClasses[0]
|
|
48
48
|
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Derives the state name from a BEM modifier class: the segment after the last `--`
|
|
52
|
+
* (`custom-states--featured` → `featured`, `card__label--in-progress` → `in-progress`).
|
|
53
|
+
* Falls back to the whole modifier when no `--` is present.
|
|
54
|
+
*/
|
|
55
|
+
export function stateNameFromModifier(modifier: string): string {
|
|
56
|
+
const separatorIndex = modifier.lastIndexOf('--')
|
|
57
|
+
return separatorIndex >= 0 ? modifier.slice(separatorIndex + 2) : modifier
|
|
58
|
+
}
|