AttributeList.js

/** Return a new list of {@link Element} attributes.
* @extends Array
*/
class AttributeList extends Array {
	/**
	* @param {AttributeArgument|void} attrs - attributes.
	*/
	constructor (attrs) {
		super();

		if (attrs === Object(attrs)) {
			this.push(...getAttributeListArray(attrs));
		}
	}

	/**
	* Add an attribute or attributes to the current {@link AttributeList}, returning whether the attribute or attributes were added.
	* @param {AttributeArgument|RegExp|string} name - Attribute to remove.
	* @param {Array.<string>} [value] - Value of the attribute to remove.
	* @example <caption>Add an empty "id" attribute.</caption>
	* attrs.add('id')
	* @example <caption>Add an "id" attribute with a value of "bar".</caption>
	* attrs.add({ id: 'bar' })
	* @example
	* attrs.add([{ name: 'id', value: 'bar' }])
	*/
	add (name, ...value) {
		return toggle(this, getAttributeListArray(name, ...value), true).attributeAdded;
	}

	/**
	* Return a new clone of the current {@link AttributeList} while conditionally applying additional attributes.
	* @param {Array<AttributeArgument|RegExp|string>} attrs - Additional attributes to be added to the new {@link AttributeList}.
	* @example
	* attrs.clone()
	* @example <caption>Clone the current attribute and add an "id" attribute with a value of "bar".</caption>
	* attrs.clone({ name: 'id', value: 'bar' })
	*/
	clone (...attrs) {
		return new AttributeList(Array.from(this).concat(getAttributeListArray(attrs)));
	}

	/**
	* Return whether an attribute or attributes exists in the current {@link AttributeList}.
	* @param {AttributeArgument|RegExp|string} name - Attribute to check.
	* @example <caption>Return whether there is an "id" attribute.</caption>
	* attrs.contains('id')
	* @example
	* attrs.contains({ id: 'bar' })
	* @example <caption>Return whether there is an "id" attribute with a value of "bar".</caption>
	* attrs.contains([{ name: 'id': value: 'bar' }])
	*/
	contains (name) {
		return this.indexOf(name) !== -1;
	}

	/**
	* Return an attribute value by name from the current {@link AttributeList} as a string or null, or false if the attribute does not exist.
	* @description If the attribute exists with a value then a String is returned. If the attribute exists with no value then `null` is returned. If the attribute does not exist then `false` is returned.
	* @param {AttributeArgument|RegExp|string} name - Name or attribute object being accessed.
	* @returns {false|null|string}
	* @example <caption>Return the value of "id" or `false`.</caption>
	* // <div>this element has no "id" attribute</div>
	* attrs.get('id') // returns false
	* // <div id>this element has an "id" attribute with no value</div>
	* attrs.get('id') // returns null
	* // <div id="">this element has an "id" attribute with a value</div>
	* attrs.get('id') // returns ''
	*/
	get (name) {
		const index = this.indexOf(name);

		return index === -1
			? false
		: this[index].value;
	}

	/**
	* Return the position of an attribute by name or attribute object in the current {@link AttributeList}.
	* @param {AttributeArgument|RegExp|string} name - Attribute to locate.
	* @param {Array<RegExp|string>} [value] - Value of the attribute to locate.
	* @example <caption>Return the index of "id".</caption>
	* attrs.indexOf('id')
	* @example <caption>Return the index of /d$/.</caption>
	* attrs.indexOf(/d$/i)
	* @example <caption>Return the index of "foo" with a value of "bar".</caption>
	* attrs.indexOf({ foo: 'bar' })
	* @example <caption>Return the index of "ariaLabel" or "aria-label" matching /^open/.</caption>
	* attrs.indexOf({ ariaLabel: /^open/ })
	* @example <caption>Return the index of an attribute whose name matches `/^foo/`.</caption>
	* attrs.indexOf([{ name: /^foo/ })
	*/
	indexOf (name, ...value) {
		return this.findIndex(
			Array.isArray(name)
				? findIndexByArray
			: isRegExp(name)
				? findIndexByRegExp
			: name === Object(name)
				? findIndexByObject
			: findIndexByString
		);

		function findIndexByArray (attr) {
			return name.some(
				innerAttr => (
					'name' in Object(innerAttr)
						? isRegExp(innerAttr.name)
							? innerAttr.name.test(attr.name)
						: String(innerAttr.name) === attr.name
					: true
				) && (
					'value' in Object(innerAttr)
						? isRegExp(innerAttr.value)
							? innerAttr.value.test(attr.value)
						: getAttributeValue(innerAttr.value) === attr.value
					: true
				)
			);
		}

		function findIndexByObject (attr) {
			const innerAttr = name[attr.name] || name[toCamelCaseString(attr.name)];

			return innerAttr
				? isRegExp(innerAttr)
					? innerAttr.test(attr.value)
				: attr.value === innerAttr
			: false;
		}

		function findIndexByRegExp (attr) {
			return name.test(attr.name) && (
				value.length
					? isRegExp(value[0])
						? value[0].test(attr.value)
					: attr.value === getAttributeValue(value[0])
				: true
			);
		}

		function findIndexByString (attr) {
			return (
				attr.name === String(name) || attr.name === toKebabCaseString(name)
			) && (
				value.length
					? isRegExp(value[0])
						? value[0].test(attr.value)
					: attr.value === getAttributeValue(value[0])
				: true
			);
		}
	}

	/**
	* Remove an attribute or attributes from the current {@link AttributeList}, returning whether any attribute was removed.
	* @param {AttributeArgument|RegExp|string} name - Attribute to remove.
	* @param {Array<string>} [value] - Value of the attribute being removed.
	* @example <caption>Remove the "id" attribute.</caption>
	* attrs.remove('id')
	* @example <caption>Remove the "id" attribute when it has a value of "bar".</caption>
	* attrs.remove('id', 'bar')
	* @example
	* attrs.remove({ id: 'bar' })
	* @example
	* attrs.remove([{ name: 'id', value: 'bar' }])
	* @example <caption>Remove the "id" and "class" attributes.</caption>
	* attrs.remove(['id', 'class'])
	*/
	remove (name, ...value) {
		return toggle(this, getAttributeListArray(name, ...value), false).attributeRemoved;
	}

	/**
	* Toggle an attribute or attributes from the current {@link AttributeList}, returning whether any attribute was added.
	* @param {AttributeArgument|RegExp|string} attr - Attribute to toggle.
	* @param {Array<{ 0: boolean }>|Array<{ 0: string }>|Array<{ 0: string, 1: boolean }>} [value] - Value of the attribute being toggled when the first argument is not an object, or attributes should be exclusively added (true) or removed (false).
	* @param {boolean} [force] - Whether attributes should be exclusively added (true) or removed (false).
	* @example <caption>Toggle the "id" attribute.</caption>
	* attrs.toggle('id')
	* @example <caption>Toggle the "id" attribute with a value of "bar".</caption>
	* attrs.toggle('id', 'bar')
	* @example
	* attrs.toggle({ id: 'bar' })
	* @example
	* attrs.toggle([{ name: 'id', value: 'bar' }])
	*/
	toggle (attr, ...value) {
		const attrs = getAttributeListArray(attr, ...value);
		const force = (
			attr === Object(attr)
				? value[0] == null ? null : Boolean(value[0])
			: value[1] == null ? null : Boolean(value[1])
		);

		const result = toggle(this, attrs, force);

		return result.attributeAdded || result.atttributeModified;
	}

	/**
	* Return the current {@link AttributeList} as a String.
	* @example
	* attrs.toString() // returns 'class="foo" data-foo="bar"'
	*/
	toString () {
		return this.length
			? `${this.map(
				attr => `${Object(attr.source).before || ' '}${attr.name}${attr.value === null ? '' : `=${Object(attr.source).quote || '"'}${attr.value}${Object(attr.source).quote || '"'}`}`
			).join('')}`
		: '';
	}

	/**
	* Return the current {@link AttributeList} as a stringifiable Object.
	* @returns {AttributePlain}
	* @example
	* attrs.toJSON() // returns { class: 'foo', dataFoo: 'bar' } when <x class="foo" data-foo: "bar" />
	*/
	toJSON () {
		return this.reduce(
			(object, attr) => Object.assign(
				object,
				{
					[toCamelCaseString(attr.name)]: attr.value
				}
			),
			{}
		);
	}

	/**
	* Return a new {@link AttributeList} from an array or object of attributes.
	* @param {Array<AttributePartial|AttributePlain>} attrs - Array or object of attributes.
	* @example <caption>Return an array of attributes from a regular object.</caption>
	* AttributeList.from({ dataFoo: 'bar' }) // returns AttributeList [{ name: 'data-foo', value: 'bar' }]
	* @example <caption>Return a normalized array of attributes from an impure array of attributes.</caption>
	* AttributeList.from([{ name: 'data-foo', value: true, foo: 'bar' }]) // returns AttributeList [{ name: 'data-foo', value: 'true' }]
	*/

	static from (attrs) {
		return new AttributeList(getAttributeListArray(attrs));
	}
}

/**
* Toggle an attribute or attributes in an {@link AttributeList}, returning an object specifying whether any attributes were added, removed, and/or modified.
* @param {AttributeList} attrs - {@link AttributeList} being modified.
* @param {Array<AttributePartial>} toggles - Attributes being toggled.
* @param {boolean} [force] - Whether attributes should be exclusively added (true) or removed (false).
* @private
*/

function toggle (attrs, toggles, force) {
	let attributeAdded = false;
	let attributeRemoved = false;
	let atttributeModified= false;

	toggles.forEach(toggleAttr => {
		const index = attrs.indexOf(toggleAttr.name);

		if (index === -1) {
			if (force !== false) {
				// add the attribute (when not exclusively removing attributes)
				attrs.push(toggleAttr);

				attributeAdded = true;
			}
		} else if (force !== true) {
			// remove the attribute (when not exclusively adding attributes)
			attrs.splice(index, 1);

			attributeRemoved = true;
		} else if (toggleAttr.value !== undefined && attrs[index].value !== toggleAttr.value) {
			// change the value of the attribute (when exclusively adding attributes)
			attrs[index].value = toggleAttr.value;

			atttributeModified = true;
		}
	});

	return { attributeAdded, attributeRemoved, atttributeModified };
}

/** Return an AttributeList-compatible array from an array or object.
* @param {Array<AttributePartial|AttributePlain>|RegExp|string|void} attrs - raw attributes.
* @param {any} [value] - optional attribute value to be used when `attr` is a primative.
* @returns {Array<AttributePartial>}
*/

function getAttributeListArray (attrs, value) {
	return attrs === null || attrs === undefined
		// void values are omitted
		? []
	: Array.isArray(attrs)
		// arrays are sanitized as a name or value, and then optionally a source
		? attrs.reduce(
			(/** @type {Array<AttributePartial>} */ attrs, rawattr) => {
				/** @type {AttributePartial} */
				const attr = {};

				if ('name' in Object(rawattr)) {
					attr.name = String(rawattr.name);
				}

				if ('value' in Object(rawattr)) {
					attr.value = getAttributeValue(rawattr.value);
				}

				if ('source' in Object(rawattr)) {
					attr.source = Object(rawattr.source);
				}

				if ('name' in attr || 'value' in attr) {
					attrs.push(attr);
				}

				return attrs;
			},
			[]
		)
	: attrs === Object(attrs)
		// objects are sanitized as a name and value
		? Object.keys(attrs).map(
			name => ({
				name: toKebabCaseString(name),
				value: getAttributeValue((/** @type {AttributePlain} */(attrs))[name])
			})
		)
	: 1 in arguments
		// both name and value arguments are sanitized as a name and value
		? [{
			name: /** @type {RegExp|string} */attrs,
			value: getAttributeValue(value)
		}]
	// one name argument is sanitized as a name
	: [{
		name: attrs
	}];
}

/**
* Return a value transformed into an attribute value.
* @description Expected values are strings. Unexpected values are null, objects, and undefined. Nulls returns null, Objects with the default toString return their JSON.stringify’d value otherwise toString’d, and Undefineds return an empty string.
* @returns {null|string}
* @example <caption>Expected values.</caption>
* getAttributeValue('foo') // returns 'foo'
* getAttributeValue('') // returns ''
* @example <caption>Unexpected values.</caption>
* getAttributeValue(null) // returns null
* getAttributeValue(undefined) // returns ''
* getAttributeValue(['foo']) // returns '["foo"]'
* getAttributeValue({ toString() { return 'bar' }}) // returns 'bar'
* getAttributeValue({ toString: 'bar' }) // returns '{"toString":"bar"}'
* @private
*/

function getAttributeValue (value) {
	return value === null
		? null
	: value === undefined
		? ''
	: value === Object(value)
		? value.toString === Object.prototype.toString
			? JSON.stringify(value)
		: String(value)
	: String(value);
}

/**
* Return a string formatted using camelCasing.
* @example
* toCamelCaseString('hello-world') // returns 'helloWorld'
* @private
*/

function toCamelCaseString (value) {
	return isKebabCase(value)
		? String(value).replace(/-[a-z]/g, $0 => $0.slice(1).toUpperCase())
	: String(value);
}

/**
* Return a string formatted using kebab-casing.
* @description Expected values do not already contain dashes.
* @example <caption>Expected values.</caption>
* toKebabCaseString('helloWorld') // returns 'hello-world'
* toKebabCaseString('helloworld') // returns 'helloworld'
* @example <caption>Unexpected values.</caption>
* toKebabCaseString('hello-World') // returns 'hello-World'
* @private
*/

function toKebabCaseString (value) {
	return isCamelCase(value)
		? String(value).replace(/[A-Z]/g, $0 => `-${$0.toLowerCase()}`)
	: String(value);
}

/**
* Return whether a value is formatted camelCase.
* @example
* isCamelCase('helloWorld')  // returns true
* isCamelCase('hello-world') // returns false
* isCamelCase('helloworld')  // returns false
* @private
*/

function isCamelCase (value) {
	return /^\w+[A-Z]\w*$/.test(value);
}

/**
* Return whether a value is formatted kebab-case.
* @example
* isKebabCase('hello-world') // returns true
* isKebabCase('helloworld')  // returns false
* isKebabCase('helloWorld')  // returns false
* @private
*/

function isKebabCase (value) {
	return /^\w+[-]\w+$/.test(value);
}

/**
* Return whether a value is a Regular Expression.
* @example
* isRegExp(/hello-world/) // returns true
* isRegExp('/hello-world/')  // returns false
* isRegExp(new RegExp('hello-world')) // returns true
* @private
*/

function isRegExp (value) {
	return Object.prototype.toString.call(value) === '[object RegExp]';
}

export default AttributeList;

/**
* @typedef {Object} AttributeSource Object representing the source configuration of an attribute
* @property {string} [before]
* @property {string} [quote]
*/
/**
* @typedef {Object} AttributePartial - Object representing a complete or partial attribute
* @property {RegExp|string} [name]
* @property {RegExp|String} [value]
* @property {AttributeSource} [source]
*/
/**
* @typedef {Object.<string,string>} AttributePlain - Object representing an attribute
*/
/**
* @typedef {AttributePartial|Array<AttributePartial>|AttributePlain|Array<AttributePlain>} AttributeArgument - Any kind of attribute
*/