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 }