import { emptyArray, isArray, isString } from '@aurelia/kernel'; import { atLayout, atNode } from '../utilities'; import type { AccessorType, IAccessor, IObserver } from '@aurelia/runtime'; import { mixinNoopSubscribable } from './observation-utils'; export interface ClassAttributeAccessor extends IObserver {} export class ClassAttributeAccessor implements IAccessor { static { mixinNoopSubscribable(ClassAttributeAccessor); } public get doNotCache(): true { return true; } public type: AccessorType = (atNode | atLayout) as AccessorType; /** @internal */ private _value: unknown = ''; /** @internal */ private readonly _nameIndex: Record = {}; /** @internal */ private _version: number = 0; public constructor( public readonly obj: HTMLElement, public readonly mapping: Record = {} ) { } public getValue(): unknown { return this._value; } public setValue(newValue: unknown): void { if (newValue !== this._value) { this._value = newValue; this._flushChanges(); } } /** @internal */ private _flushChanges(): void { const nameIndex = this._nameIndex; const version = ++this._version; const classList = this.obj.classList; const classesToAdd = getClassesToAdd(this._value as string | Record | []); const ii = classesToAdd.length; let i = 0; let name: string; // Get strings split on a space not including empties if (ii > 0) { for (; i < ii; i++) { name = classesToAdd[i]; name = this.mapping[name] || name; if (name.length === 0) { continue; } nameIndex[name] = this._version; classList.add(name); } } // First call to setValue? We're done. if (version === 1) { return; } for (name in nameIndex) { name = this.mapping[name] || name; if (nameIndex[name] === version) { continue; } // TODO: this has the side-effect that classes already present which are added again, // will be removed if they're not present in the next update. // Better would be do have some configurability for this behavior, allowing the user to // decide whether initial classes always need to be kept, always removed, or something in between classList.remove(name); } } } function getClassesToAdd(object: Record | [] | string): string[] { if (isString(object)) { return splitClassString(object); } if (typeof object !== 'object') { return emptyArray; } if (isArray(object)) { const len = object.length; if (len > 0) { const classes: string[] = []; let i = 0; for (; len > i; ++i) { classes.push(...getClassesToAdd(object[i])); } return classes; } else { return emptyArray; } } const classes: string[] = []; let property: string; for (property in object) { // Let non typical values also evaluate true so disable bool check // eslint-disable-next-line no-extra-boolean-cast if (Boolean(object[property])) { // We must do this in case object property has a space in the name which results in two classes if (property.includes(' ')) { classes.push(...splitClassString(property)); } else { classes.push(property); } } } return classes; } function splitClassString(classString: string): string[] { const matches = classString.match(/\S+/g); if (matches === null) { return emptyArray; } return matches; }