import type * as Style from '../index.js'
import { isTypeInCategory, isTypeElementAspect } from '../definition.js'
import * as Conventions from './conventions.js'
import * as Rule from './rule.js'
import * as Set from './set.js'
import * as Definitions from './definitions.js'
/**
* # Class list methods
*
* Application and resolution of style rules is done in context of class list. It is enough to tell which element, rule
* and combo styles are applied to an element. In cases where combo styles need to be resolved it may require theme
* context class list as well. For elements that do not have actual class attribute assigned like `
` or ``
* the class list is automatically augmented with _implicit class_ by @see Context#getClassName
*/
/**
* Parses a class name string into a normalized class list.
*
* @param {string} string - A string containing class names separated by spaces.
* @returns {string[]} An array of normalized class names.
* @example
*
* fromClassName('classA classB') // ['classA', 'classB']
* fromClassName('classA classB classA') // ['classA', 'classB']
*
*/
export function fromClassName(string: string) {
return normalize(
String(string || '')
.split(/\s+/g)
.filter(Boolean)
)
}
/**
* Adds a class or a list of classes to the class list while keeping it normalized.
*
* @param {string[]} classList - The existing list of class names.
* @param {string | string[]} newClass - The new class name(s) to add.
* @returns {string[]} An array of class names with the new class(es) added.
* @example
*
* concat(['classA', 'classB'], 'classC') // ['classA', 'classB', 'classC']
* concat(['classA', 'classB'], ['classC', 'classD']) // ['classA', 'classB', 'classC', 'classD']
* concat(['classA', 'classB'], ['classC', 'classB']) // ['classA', 'classB', 'classC']
*
*/
export function concat(classList: string[], newClass: string | string[]) {
return normalize(classList.concat(newClass || []))
}
/**
* Normalizes a class list by sorting it and removing duplicates.
*
* @param {string[]} classList - The existing list of class names.
* @returns {string[]} An array of class names sorted and without duplicates.
* @example
*
* normalize(['classA', 'classB', 'classA']) // ['classA', 'classB']
* normalize(['classB', 'classA', 'classC', 'classB']) // ['classA', 'classB', 'classC']
*
*/
export function normalize(classList: string[]) {
return sort(classList).filter((v, i, a) => a.indexOf(v) === i)
}
/**
* Sorts a class list in alphabetical order, with the element class name at the beginning.
*
* @param {string[]} classList - The existing list of class names.
* @returns {string[]} An array of class names sorted as described.
* @example
*
* sort(['classB', '-elementA', 'classA']) // ['-elementA', 'classA', 'classB']
* sort(['classC', 'classA', '-elementB', 'classB']) // ['-elementB', 'classA', 'classB', 'classC']
*
*/
export function sort(classList: string[]) {
const getSortIndex = (className: string) =>
// first tag name class
Number(Conventions.isElementClassName(className)) * 1000 +
// then instance class
Number(className.startsWith('-id--')) * 100
return classList.slice().sort((a, b) => {
return Number(getSortIndex(b)) - Number(getSortIndex(a)) || (a < b ? -1 : a > b ? 1 : 0)
})
}
/**
* Swaps an element style in the class list. When changing element styles, also resets aspect styles. If passed an empty
* style, resets the class list to the generic class name, effectively making it subject to theme style.
*
* @param {string[]} classList - The existing list of class names.
* @param {Style.Rule | Style.Empty} style - The style to set.
* @returns {string[]} An array of class names with the style set.
* @example
*
* setStyle(['-block', 'classA'], styleObject) // ['-block--newStyle', 'classA']
* setStyle(['-block--styleA', 'classA'], {type: 'block}) // ['-elementA', 'classA']
*
*/
export function setStyle(classList: string[], style: Style.Rule | Style.Empty) {
var definition = 'props' in style && Rule.getElementDefinition(style)
const isElement = isTypeInCategory(style.type, 'element')
return concat(
classList.filter((className) => {
const type = className.match(/^\-(.*?)(\-\-|\s|$)/)?.[1]
if (style.type == type) return false
if (isElement) {
// remove combos when assigning element style
if (className.startsWith('--')) return false
// remove element aspects when assigning element style
if (type && isTypeElementAspect(type as Style.Type)) return false
// keep the style if matches definition's disguise
const def = Conventions.getDefinitionFromClassName(className)
if (def && definition && def.disguise == definition.name) {
return true
}
// remove non-matching element type
if (def) return false
}
// retain all other class names
return true
}),
'props' in style ? Rule.getClassName(style) : getElementGenericClassName(classList)
)
}
/**
* Swaps a theme in the class list and removes the previously assigned combos.
*
* @param {string[]} classList - The existing list of class names.
* @param {Style.Rule<'theme'>} theme - The theme to set.
* @returns {string[]} An array of class names with the theme set.
* @example
*
* setTheme(['-theme--oldTheme', 'classA'], themeObject) // ['-theme--newTheme', 'classA']
* setTheme(['-theme--oldTheme', '--use--combo'], themeObject) // ['-theme--newTheme']
* setTheme(['classA', 'classB'], themeObject) // ['-theme--newTheme', 'classA', 'classB']
*
*/
export function setTheme(classList: string[], theme: Style.Rule<'theme'>) {
return concat(
classList.filter((className) => !className.startsWith('-use--') && !className.startsWith('-theme--')),
theme ? '-theme--' + theme.details.slug : []
)
}
/**
* Applies a -use--combo.id to the class list and removes deleted combos and combos that belong to the same category as
* the selected combo. It will clean up theme references to theme combos that don't exist as well.
*
* @param {string[]} classList - The existing list of class names.
* @param {Style.Theme.Combo} combo - The combo to set.
* @param {Style.Rule[]} styles - The array of style rules.
* @returns {string[]} An array of class names with the combo set.
* @example
*
* setThemeCombo(['-use--oldParagraphCombo', 'classA'], comboObject, stylesArray) // ['-use--newParagraphCombo', 'classA']
* setThemeCombo(['classA', 'classB'], comboObject, stylesArray) // ['-use--newCombo', 'classA', 'classB']
*
*/
export function setThemeCombo(classList: string[], combo: Style.Theme.Combo, styles: Style.Rule[]) {
const comboStyle = Set.findById(styles, combo.refId)
const theme = Set.findThemeByComboId(styles, combo.id)
return concat(
classList.filter((className) => {
if (!className.startsWith('-use--')) return true
const currentComboStyle = Set.findElementByThemeAndComboId(styles, theme, className.replace('-use--', ''))
return currentComboStyle && comboStyle.details.collectionId !== currentComboStyle.details.collectionId
}),
combo.isDefault ? null : `-use--${combo.id}`
)
}
/**
* Removes a specific theme combo from the class list by its id.
*
* @param {string[]} classList - The existing list of class names.
* @param {Style.Theme.Combo} combo - The combo to be removed.
* @returns {string[]} An array of class names with the specified combo removed.
* @example
*
* removeThemeCombo(['-use--oldParagraphCombo', 'classA'], oldParagraphCombo) // ['classA']
* removeThemeCombo(['classA', 'classB'], nonExistingCombo) // ['classA', 'classB']
*
*/
export function removeThemeCombo(classList: string[], combo: Style.Theme.Combo) {
return classList.filter((className) => '-use--' + combo.id != className)
}
/**
* Swaps, applies, or removes a combo from the class list. Combos are identified by the `--uid` format of their class
* names.
*
* @param {string[]} classList - The existing list of class names.
* @param {Style.Theme.Combo} combo - The combo to set.
* @returns {string[]} An array of class names with the combo set.
* @example
*
* setCombo(['--oldCombo', 'classA'], comboObject) // ['--newCombo', 'classA']
* setCombo(['classA', 'classB'], comboObject) // ['--newCombo', 'classA', 'classB']
*
*/
export function setCombo(classList: string[], combo: Style.Theme.Combo) {
return concat(
classList.filter((c) => !c.startsWith(`--`)),
combo ? `--${combo.id}` : []
)
}
/**
* Given a set of combos, returns the applied combo or the default one.
*
* @param {string[]} classList - The existing list of class names.
* @param {Style.Theme.Combo[]} combos - The array of combos.
* @returns {Style.Theme.Combo} The applied or default combo.
* @example
*
* findCombo(['--comboA', 'classA'], combosArray) // comboA_Object
* findCombo(['classA', 'classB'], combosArray) // defaultCombo_Object
*
*/
export function findCombo(classList: string[], combos: Style.Theme.Combo[]) {
return combos.find((combo) => classList.includes(`--${combo.id}`)) || combos.find((combo) => combo.isDefault)
}
/**
* Finds the element class name in a list and simplifies it to remove the specific style.
*
* @param {string[]} classList - The existing list of class names.
* @returns {string} The generic class name of the element.
* @example
*
* getElementGenericClassName(['-paragraph--styleA', 'classA']) // '-paragraph'
* getElementGenericClassName(['-block--hero--styleB', 'classB', 'classC']) // '-block--hero'
*
*/
export function getElementGenericClassName(classList: string[]) {
return Conventions.getElementGenericClassName(getElementClassName(classList))
}
/**
* Finds the element class name in a list. If there're multiple class that can be definitions, prioritize them.
*
* @param {string[]} classList - The existing list of class names.
* @returns {string} The class name of the element.
* @example
*
* getElementClassName(['-card', 'classA']) // '-element
* getElementClassName(['-card--styleA', 'classA']) // '-elementA--styleA'
* getElementClassName(['-inline--badge--styleB', 'classB', 'classC']) // '-inline--badge--styleB'
*
*/
export function getElementClassName(classList: string[], definitions: Style.ElementDefinition[] = Definitions.all) {
return classList.filter(Conventions.isElementClassName).sort((a, b) => {
const aIndex = definitions.findIndex((d) => Conventions.matchClassName(a, d.className))
const bIndex = definitions.findIndex((d) => Conventions.matchClassName(b, d.className))
return aIndex - bIndex
})[0]
}
/**
* Finds the definition for a class list.
*
* @param {string[]} classList - The list of class names.
* @returns {Style.ElementDefinition} The corresponding element definition.
* @example
*
* getDefinition(['-card--styleA', 'classA']) // Style.Rule.Element
* getDefinition(['-inline--badge', 'classB']) // Style.Rule.Element
*
*/
export function getDefinition(classList: string[]) {
return (
Conventions.getDefinitionFromClassName(getElementClassName(classList)) ||
Definitions.all.find((d) => d.name == 'container')
)
}
/**
* Checks if a class list contains an Element style class name.
*
* @param {string[]} classList - The list of class names.
* @param {Style.Rule.Element} style - The Element style rule.
* @returns {boolean} True if the Element style class name is in the class list, false otherwise.
* @example
*
* matchesElement(['-card--styleA', 'classA'], elementAStyle) // true
* matchesElement(['-card--styleB', 'classB'], elementAStyle) // false
*
*/
export function matchesElement(classList: string[], style: Style.Rule.Element) {
const ruleClassName = Rule.getClassName(style)
return classList.some((className) => className == ruleClassName)
}
/**
* Checks if a class list contains class names that match a given definition.
*
* @param {string[]} classList - The list of class names.
* @param {Style.ElementDefinition} definition - The element definition to match.
* @returns {boolean} True if a class name in the list matches the definition, false otherwise.
* @example
*
* matchesDefinition(['-card--styleA', 'classA'], cardDefinition) // true
* matchesDefinition(['-section--styleB', 'classB'], cardDefinition) // false
*
*/
export function matchesDefinition(classList: string[], definition: Style.ElementDefinition) {
return classList.some((className) => className == definition.className)
}
/**
* Checks if a combo id in the class list matches an element.
*
* @param {string[]} classList - The list of class names.
* @param {string} comboId - The combo id to match.
* @param {Style.Rule[]} styles - The set of style rules.
* @returns {boolean} True if the combo id matches the element, false otherwise.
* @example
*
* matchesComboId(['-card--styleA'], cardCombo.id, styles) // true
* matchesComboId(['-section--styleB'], cardCombo.id, styles) // false
*
*/
export function matchesComboId(classList: string[], comboId: string, styles: Style.Rule[]) {
const element = Set.findElementByComboId(styles, comboId)
if (!element) return
const definition = Rule.getElementDefinition(element)
return definition && matchesDefinition(classList, definition)
}
/**
* Finds the theme slug in a class list.
*
* @param {string[]} classList - The list of class names.
* @returns {string} The theme slug if found, undefined otherwise.
* @example
*
* getThemeSlug(['-theme--default', '-use--deadbeef']) // 'default'
* getThemeSlug(['-theme--custom', '-use--deadbeef']) // 'custom'
* getThemeSlug(['-use--deadbeef']) // undefined
*
*/
export function getThemeSlug(classList: string[]) {
return classList.find((c) => c.startsWith('-theme--'))?.replace('-theme--', '')
}
/**
* Lists all combo ids in a class list of theme context.
*
* @param {string[]} classList - The list of class names.
* @returns {string[]} An array of combo ids found in the class list.
* @example
*
* getThemeComboIds(['-theme--default', '-use--deadbeef']) // ['deadbeef']
* getThemeComboIds(['-theme--default', '-use--deadbeef', '-use--baddad']) // ['deadbeef', 'baddad']
* getThemeComboIds(['-theme--default']) // []
*
*/
export function getThemeComboIds(classList: string[]) {
return classList.filter((c) => c.startsWith('-use--')).map((c) => c.replace('-use--', ''))
}
/**
* Lists all combo ids in a class list of element context.
*
* @param {string[]} classList - The list of class names.
* @returns {string | null} The combo id if found, null otherwise.
* @example
*
* getComboId(['-section', '--deadbeef']) // 'deadbeef'
* getComboId(['-section']) // null
*
*/
export function getComboId(classList: string[]) {
return classList.find((c) => c.startsWith('--'))?.replace('--', '') ?? null
}
/**
* Enriches a class list with an implicit class based on the context name.
*
* @param {string[]} classList - The list of class names.
* @param {string} contextName - The context name.
* @returns {string[]} The expanded class list with the implicit class added if applicable.
* @example
*
* expand(['abc'], 'section') // ['-section', 'abc']
* expand(['-section--special', 'abc'], 'section') // ['-section--special', 'abc']
*
*/
export function expand(classList: string[], contextName: string) {
if (contextName && !classList.some((className) => Conventions.isElementClassName(className))) {
const definition = Definitions.all.find((d) => d.name == contextName)
if (definition?.isImplicit) return [definition.className, ...classList]
}
return classList
}