@liturgical-calendar/components-js
Version:
Liturgical calendar components for javascript: an html select populated with liturgical calendars supported by the Liturgical Calendar API; form controls for parameters that are supported by the Liturgical Calendar API; a webcalendar; and liturgy of the d
1,034 lines (983 loc) • 51.3 kB
JavaScript
import ApiClient from '../ApiClient/ApiClient.js';
import Messages from '../Messages.js';
import Input from '../ApiOptions/Input/Input.js';
import { CalendarSelectFilter } from '../Enums.js';
import Utils from '../Utils.js';
/**
* Creates a select menu populated with available liturgical calendars from the Liturgical Calendar API
*
* @example
* const calendarSelect = new CalendarSelect();
* calendarSelect.appendTo( '#calendar-select' );
*
* @example
* const calendarSelect = new CalendarSelect('it-IT');
* calendarSelect.allowNull().label({
class: 'form-label d-block mb-1',
id: 'liturgicalCalendarSelectItaLabel',
text: 'Seleziona calendario'
}).wrapper({
class: 'form-group col col-md-3',
id: 'liturgicalCalendarSelectItaWrapper'
}).id('liturgicalCalendarSelectEng').class('form-select').replace( '#calendar-select' );
*
* @author [John Romano D'Orazio](https://github.com/JohnRDOrazio)
* @license Apache-2.0
* @see https://github.com/Liturgical-Calendar/liturgy-components-js
*/
export default class CalendarSelect {
static #metadata = null;
static #nationalCalendars = [];
static #diocesanCalendars = [];
static #nationalCalendarsWithDioceses = [];
#nationOptions = [];
#dioceseOptions = {};
#dioceseOptionsGrouped = [];
#countryNames = null;
#locale = 'en';
#filter = CalendarSelectFilter.NONE;
#domElement = null;
#afterElement = null;
#hasAfter = false;
#afterSet = false;
#labelElement = null;
#hasLabel = false;
#labelSet = false;
#wrapperElement = null;
#hasWrapper = false;
#wrapperSet = false;
#filterSet = false;
#linked = false;
#idSet = false;
#nameSet = false;
#allowNull = false;
/**
* Validates if the given class name adheres to standard CSS class naming conventions.
*
* The regex pattern used to validate class names:
* - `^` asserts the start of a line
* - `(?!\d|--|-?\d)` is a negative lookahead that prevents the class name
* from starting with a digit, or a sequence of dashes, or a number with a leading dash
* - `[a-zA-Z_-]` matches any character that is a letter, a dash or an underscore
* - `[a-zA-Z\d_-]{1,}` matches any alphanumeric character, a dash or an underscore at least once
* - `$` asserts the end of a line
*
* @param {string} className - The class name to validate.
* @returns {boolean} True if the class name is valid, false otherwise.
* @private
* @static
*/
static #isValidClassName(className) {
const pattern = /^(?!\d|--|-?\d)[a-zA-Z_-][a-zA-Z\d_-]{1,}$/;
return pattern.test(className);
}
/**
* Validates if the given ID adheres to HTML and CSS ID naming conventions.
*
* The regex pattern used to validate IDs:
* - `^` asserts the start of a line
* - `(?!\d|--|-?\d)` is a negative lookahead that prevents the ID
* from starting with a digit, a sequence of dashes, or a number with a leading dash
* - `(?:[_-][a-zA-Z][\w\-]*|[a-zA-Z][\w\-]*)` matches either a sequence starting with an underscore or dash
* followed by a letter and zero or more word characters or dashes,
* or it matches a letter followed by zero or more word characters or dashes
* - `$` asserts the end of a line
*
* Note: While ID attribute values can contain any Unicode character,
* they must be valid CSS identifiers when used in CSS selectors or with JavaScript methods like `querySelector`.
*
* @param {string} id - The ID to validate.
* @returns {boolean} True if the ID is valid, false otherwise.
* @private
* @static
*/
static #isValidId(id) {
const pattern = /^(?!\d|--|-?\d)(?:[_-][a-zA-Z][\w\-]*|[a-zA-Z][\w\-]*)$/;
return pattern.test(id);
}
/**
* Returns true if we have already stored a national calendar with dioceses for the given nation,
* that is when diocesan calendars belong to the same national calendar, and false otherwise.
*
* @param {string} nation - The nation to check.
* @returns {boolean} True if we have stored a national calendar with dioceses for the given nation, false otherwise.
* @private
* @static
*/
static #hasNationalCalendarWithDioceses( nation ) {
return CalendarSelect.#nationalCalendarsWithDioceses.filter(item => item?.calendar_id === nation).length > 0;
}
/**
* Adds a national calendar with dioceses for the given nation.
*
* This internal method is used to add a national calendar with dioceses to the list of national calendars with dioceses.
* This will also initialize diocese select options for the given nation.
*
* @param {string} nation - The nation for which we should add the national calendar.
* @private
* @static
*/
static #addNationalCalendarWithDioceses( nation ) {
const nationalCalendar = CalendarSelect.#nationalCalendars.find(item => item.calendar_id === nation);
CalendarSelect.#nationalCalendarsWithDioceses.push( nationalCalendar );
}
/**
* Initializes the CalendarSelect class.
*
* This method initializes the CalendarSelect class by storing the metadata obtained from the ApiClient
* class in a private class property. This method must be called before any CalendarSelect instances are created.
* If the ApiClient class has not been initialized, or failed to initialize, an error will be thrown.
*
* @throws {Error} If the ApiClient class has not been initialized.
* @throws {Error} If the ApiClient class failed to initialize.
* @throws {Error} If the ApiClient class initialized with an invalid object.
* @throws {Error} If the ApiClient class initialized with an object that does not contain the expected properties.
* @static
* @private
*/
static #init() {
if ( null === ApiClient._metadata ) {
throw new Error('ApiClient has not been initialized. Please initialize with `ApiClient.init().then(() => { ... })`, and handle the CalendarSelect instances within the callback.');
} else {
if (ApiClient._metadata === false) {
throw new Error('The ApiClient class was unable to initialize.');
}
if (typeof ApiClient._metadata !== 'object') {
throw new Error('The ApiClient class was unable to initialize: expected object, found ' + typeof ApiClient._metadata + '.');
}
if (false === ApiClient._metadata.hasOwnProperty('national_calendars') || false === ApiClient._metadata.hasOwnProperty('diocesan_calendars')) {
throw new Error('The ApiClient class was unable to initialize: expected object with `national_calendars` and `diocesan_calendars` properties.');
}
CalendarSelect.#metadata = ApiClient._metadata;
CalendarSelect.#nationalCalendars = CalendarSelect.#metadata.national_calendars;
CalendarSelect.#diocesanCalendars = CalendarSelect.#metadata.diocesan_calendars;
}
}
/**
* Constructor for the CalendarSelect class.
*
* @param {Object|string} [options] - The options object or locale string. An options object can have the following properties:
* - locale: The locale to use for the CalendarSelect UI elements.
* - id: The ID of the CalendarSelect element.
* - class: The class name for the CalendarSelect element.
* - name: The name for the CalendarSelect element.
* - filter: The CalendarSelectFilter to apply to the CalendarSelect element.
* - after: an html string to append after the CalendarSelect element.
* - allowNull: a boolean to indicate if the CalendarSelect element should allow null values.
* - disabled: a boolean to indicate if the CalendarSelect element should be disabled.
* - label: The label for the CalendarSelect element (an object with a `text` property, and optionally `class` and `id` properties).
* - wrapper: The wrapper for the CalendarSelect element (an object with an `as` property, and optionally `class` and `id` properties).
* If a string is passed, it is expected to be the locale code to use for the CalendarSelect UI elements.
* The locale should be a valid string that can be parsed by the Intl.getCanonicalLocales function.
* If the locale string contains an underscore, the underscore will be replaced with a hyphen.
*
* @throws {Error} If the locale is invalid.
*/
constructor(options) {
if (typeof options === 'string') {
options = { locale: options };
}
else if (null === options) {
options = { locale: 'en' };
}
else if (typeof options !== 'object' || Array.isArray(options)) {
const optionsType = Array.isArray(options) ? 'array' : typeof options;
throw new Error('Invalid type for options, must be of type `object` but found type: ' + optionsType);
}
const { locale, id, name, filter, after, label, wrapper, allowNull, disabled } = options;
if (locale) {
if (typeof locale !== 'string') {
throw new Error('Invalid type for locale, must be of type `string` but found type: ' + typeof locale);
}
if (locale.includes('_')) {
locale = locale.replaceAll('_', '-');
}
try {
const canonicalLocales = Intl.getCanonicalLocales(locale);
if (canonicalLocales.length === 0) {
throw new Error('Invalid locale: ' + locale);
}
this.#locale = canonicalLocales[0];
this.#countryNames = new Intl.DisplayNames( [ this.#locale ], { type: 'region' } );
} catch (e) {
throw new Error('Invalid locale: ' + locale);
}
} else {
this.#locale = 'en';
this.#countryNames = new Intl.DisplayNames( [ this.#locale ], { type: 'region' } );
}
if (null === CalendarSelect.#metadata) {
CalendarSelect.#init();
}
this.#buildAllOptions();
this.#domElement = document.createElement('select');
if (options.hasOwnProperty('class')) {
this.class(options.class);
}
if (id) {
this.id(id);
}
if (name) {
this.name(name);
}
if (filter) {
this.filter(filter);
} else {
this.filter(this.#filter);
}
if (after) {
this.after(after);
}
if (label) {
this.label(label);
}
if (wrapper) {
this.wrapper(wrapper);
}
if (allowNull) {
this.allowNull(allowNull);
}
if (disabled) {
this.disabled(disabled);
}
}
/**
* Filters and updates the diocese options for the specified nation.
*
* If the nation has no associated diocese options, the select element will display a placeholder option.
* Otherwise, it populates the select element with the diocese options for the specified nation.
*
* @param {string} nation - The nation for which to filter diocese options.
* @private
*/
#filterDioceseOptionsForNation(nation) {
if (false === this.#dioceseOptions.hasOwnProperty(nation)) {
this.#domElement.innerHTML = '<option value="">---</option>';
} else {
const firstElement = this.#allowNull ? '<option value="">---</option>' : '';
this.#domElement.innerHTML = firstElement + this.#dioceseOptions[nation].join('');
}
}
/**
* Adds an option for a national calendar to the select element.
*
* @param {Object} nationalCalendar - The national calendar object containing calendar information.
* @param {boolean} [selected=false] - Indicates if the option should be selected by default.
* @private
*/
#addNationOption(nationalCalendar, selected = false) {
const option = `<option data-calendartype="national" value="${nationalCalendar.calendar_id}"${selected ? ' selected' : ''}>${this.#countryNames.of(nationalCalendar.calendar_id)}</option>`;
this.#nationOptions.push( option );
}
/**
* Adds a select option for a diocesan calendar to the list of diocese options for the given nation.
*
* @param {Object} item - The diocesan calendar object containing calendar information.
* @param {string} item.calendar_id - The ID for the calendar (corresponding to the unique id of the diocese).
* @param {string} item.nation - The nation that the diocesan calendar belongs to.
* @param {string} item.diocese - The name of the diocese.
* @private
*/
#addDioceseOption(item) {
const option = `<option data-calendartype="diocesan" value="${item.calendar_id}">${item.diocese}</option>`;
this.#dioceseOptions[item.nation].push(option);
}
/**
* Builds all options for the calendar select element.
*
* This method builds the options for the calendar select element by iterating over the diocesan calendars,
* adding options for each diocesan calendar, and adding options for each national calendar.
*
* @private
*/
#buildAllOptions() {
CalendarSelect.#diocesanCalendars.forEach( diocesanCalendarObj => {
if ( false === CalendarSelect.#hasNationalCalendarWithDioceses( diocesanCalendarObj.nation ) ) {
// we add all nations with dioceses to the nations list
CalendarSelect.#addNationalCalendarWithDioceses( diocesanCalendarObj.nation );
}
if ( false === this.#dioceseOptions.hasOwnProperty( diocesanCalendarObj.nation ) ) {
this.#dioceseOptions[ diocesanCalendarObj.nation ] = [];
}
this.#addDioceseOption( diocesanCalendarObj );
} );
CalendarSelect.#nationalCalendars.sort( ( a, b ) => this.#countryNames.of( a.calendar_id ).localeCompare( this.#countryNames.of( b.calendar_id ) ) );
CalendarSelect.#nationalCalendars.forEach( nationalCalendar => {
if ( false === CalendarSelect.#hasNationalCalendarWithDioceses( nationalCalendar.calendar_id ) ) {
// This is the first time we call CalendarSelect.#addNationOption().
// This will ensure that the VATICAN (a nation without any diocese) will be added as the first option.
// In theory any other nation for whom no dioceses are defined will be added here too,
// so we will ensure that the VATICAN is always the default selected option
if ( 'VA' === nationalCalendar.calendar_id ) {
this.#addNationOption( nationalCalendar, true );
} else {
this.#addNationOption( nationalCalendar );
}
}
} );
// now we can add the options for the nations in the #calendarNationsWithDiocese list
// that is to say, nations that have dioceses
CalendarSelect.#nationalCalendarsWithDioceses.sort( ( a, b ) => this.#countryNames.of( a.calendar_id ).localeCompare( this.#countryNames.of( b.calendar_id ) ) );
CalendarSelect.#nationalCalendarsWithDioceses.forEach( nationalCalendar => {
this.#addNationOption( nationalCalendar );
let optGroup = `<optgroup label="${this.#countryNames.of( nationalCalendar.calendar_id )}">${this.#dioceseOptions[ nationalCalendar.calendar_id ].join( '' )}</optgroup>`;
this.#dioceseOptionsGrouped.push( optGroup );
} );
return this;
}
/**
* Retrieves the HTML string for the nation options.
*
* This getter method concatenates all the nation options into a single HTML string,
* which can be used to populate a select element with nation options.
*
* @returns {string} A concatenated string of all nation options in HTML format.
*/
get nationsInnerHtml() {
return this.#nationOptions.join( '' );
}
/**
* Retrieves the HTML string for the diocese options grouped by nation.
*
* This getter method concatenates all the diocese options grouped by nation into a single HTML string,
* which can be used to populate a select element with diocese options for each nation.
*
* @returns {string} A concatenated string of all diocese options grouped by nation in HTML format.
*/
get diocesesInnerHtml() {
return this.#dioceseOptionsGrouped.join( '' );
}
/**
* Sets the filter for the select element.
*
* The filter can be either `CalendarSelectFilter.NATIONAL_CALENDARS`, `CalendarSelectFilter.DIOCESAN_CALENDARS`, or `CalendarSelectFilter.NONE`.
* - `CalendarSelectFilter.NATIONAL_CALENDARS` will show only the nation options.
* - `CalendarSelectFilter.DIOCESAN_CALENDARS` will show only the diocese options grouped by nation.
* - `CalendarSelectFilter.NONE` will show all options, that is, for all calendars whether national or diocesan.
*
* If the filter is set to a value that is not a valid value for the `CalendarSelectFilter` enum,
* an error will be thrown.
*
* If the filter is set to a value that is different from the current filter,
* the innerHTML of the select element will be updated accordingly, and will not be able to be set again.
*
* @param {string} [filter=CalendarSelectFilter.NONE] The filter to set.
* @returns {this}
*/
filter( filter = CalendarSelectFilter.NONE ) {
if ( this.#filterSet && this.#filter !== filter ) {
throw new Error('Filter has already been set to `' + this.#filter + '` on CalendarSelect instance with locale ' + this.#locale + '.');
}
if ( CalendarSelectFilter.NATIONAL_CALENDARS !== filter && CalendarSelectFilter.DIOCESAN_CALENDARS !== filter && CalendarSelectFilter.NONE !== filter ) {
throw new Error('Invalid filter: ' + filter);
}
this.#filter = filter;
const firstElement = this.#allowNull ? '<option value="">---</option>' : '';
if ( this.#filter === CalendarSelectFilter.NATIONAL_CALENDARS ) {
this.#domElement.innerHTML = firstElement + this.nationsInnerHtml;
} else if ( this.#filter === CalendarSelectFilter.DIOCESAN_CALENDARS ) {
this.#domElement.innerHTML = firstElement + this.diocesesInnerHtml;
} else {
this.#domElement.innerHTML = firstElement + this.nationsInnerHtml + this.diocesesInnerHtml;
}
if ( filter !== this.#filter ) {
this.#filterSet = true;
}
return this;
}
/**
* Sets the class attribute for the CalendarSelect instance's DOM element.
*
* Validates the input class name(s) to ensure they are strings and conform to
* CSS class naming conventions. If the class name is valid, it is sanitized
* and assigned to the element. If the class name is an empty string, the
* class attribute is removed.
*
* @param {string} className - A space-separated string of class names to be
* assigned to the DOM element.
* @throws {Error} If the className is not a string, or if any class name is
* invalid.
* @returns {CalendarSelect} The current CalendarSelect instance for chaining.
*/
class( className ) {
if ( typeof className !== 'string' ) {
throw new Error('Invalid type for class name on CalendarSelect instance with locale ' + this.#locale + ', must be of type string but found type: ' + typeof className);
}
let classNames = className.split( /\s+/ );
classNames = classNames.map( className => Utils.sanitizeInput( className ) );
classNames.forEach(className => {
if ( false === Utils.validateClassName( className ) ) {
throw new Error('Invalid class name: ' + className);
}
});
className = classNames.join( ' ' );
if ( className === '' ) {
this.#domElement.removeAttribute( 'class' );
} else {
this.#domElement.setAttribute( 'class', className );
}
return this;
}
/**
* Sets the id attribute of the select element.
*
* Validates the input id to ensure it is a string and conforms to
* HTML id attribute naming conventions. If the id is valid, it is sanitized
* and assigned to the element. If the id is an empty string, the
* id attribute is removed.
*
* If the id has already been set, an error will be thrown.
*
* If the label has already been set, the for attribute of the label element
* will be updated to match the new id.
*
* @param {string} id The id attribute of the select element.
* @throws {Error} If the id is not a string, or if the id is invalid.
* @returns {CalendarSelect} The current CalendarSelect instance for chaining.
*/
id( id ) {
if ( this.#idSet && this.#domElement.id !== id ) {
throw new Error('ID has already been set to `' + this.#domElement.id + '` on CalendarSelect instance with locale ' + this.#locale + '.');
}
if ( typeof id !== 'string' ) {
throw new Error('Invalid type for id, must be of type string but found type: ' + typeof id);
}
id = Utils.sanitizeInput( id );
if (Utils.validateId( id ) === false) {
throw new Error('Invalid id, cannot contain any kind of whitespace character: ' + id);
}
this.#domElement.id = id;
if (this.#hasLabel) {
this.#labelElement.setAttribute( 'for', this.#domElement.id );
if (this.#labelElement.hasAttribute( 'id' )) {
this.#domElement.setAttribute( 'aria-labelledby', this.#labelElement.id );
}
}
this.#idSet = true;
return this;
}
/**
* Sets the name attribute of the select element.
*
* Validates the input name to ensure it is a string. If the name is valid,
* it is sanitized and assigned to the element. If the name is an empty
* string, the name attribute is removed.
*
* If the name has already been set, an error will be thrown.
*
* @param {string} name The name attribute of the select element.
* @throws {Error} If the name is not a string, or if the name has already been set.
* @returns {CalendarSelect} The current CalendarSelect instance for chaining.
*/
name( name ) {
if ( this.#nameSet && this.#domElement.name !== name ) {
throw new Error('Name has already been set to `' + this.#domElement.name + '` on CalendarSelect instance with locale ' + this.#locale + '.');
}
if ( typeof name !== 'string' ) {
throw new Error('Invalid type for name, must be of type string but found type: ' + typeof name);
}
this.#domElement.name = name;
this.#nameSet = true;
return this;
}
/**
* Configures the label element for the CalendarSelect instance.
*
* If label options are not provided, the label will not be created and
* any existing label will be removed. If an object is provided, it
* can specify label attributes such as class, id, and text. The method
* validates the options and sets the label accordingly.
*
* @param {Object|null} labelOptions An object specifying label options or null to disable the label.
* @param {string} [labelOptions.class] CSS classes to apply to the label element.
* @param {string} [labelOptions.id] The id attribute for the label element.
* @param {string} [labelOptions.text] The text content for the label element.
*
* @throws {Error} If the label options are not an object, or if the class, id, or text are not valid strings.
*
* @returns {CalendarSelect} The current CalendarSelect instance for chaining.
*/
label( labelOptions = null ) {
if ( this.#labelSet ) {
throw new Error('Label has already been set on CalendarSelect instance with locale ' + this.#locale + '.');
}
if ( null === labelOptions ) {
this.#hasLabel = false;
this.#labelElement = null;
this.#domElement.removeAttribute( 'aria-labelledby' );
this.#labelSet = true;
return this;
}
else if ( typeof labelOptions !== 'object' || Array.isArray(labelOptions) ) {
const labelOptionsType = Array.isArray(labelOptions) ? 'array' : typeof labelOptions;
throw new Error('Invalid type for label options, must be of type object (not null or array) but found type: ' + labelOptionsType);
}
else if ( Object.keys( labelOptions ).length === 0 || false === Object.keys( labelOptions ).some( key => ['class', 'id', 'text'].includes( key ) ) ) {
throw new Error('Invalid label options, must be an object with at least a `text`, `class` or `id` property');
}
this.#labelElement = document.createElement( 'label' );
this.#hasLabel = true;
this.#labelSet = true;
if ( this.#domElement.hasAttribute( 'id' ) ) {
this.#labelElement.setAttribute( 'for', this.#domElement.id );
}
if ( labelOptions.hasOwnProperty( 'class') ) {
if ( typeof labelOptions.class !== 'string' ) {
throw new Error('Invalid type for label class, must be of type string but found type: ' + typeof labelOptions.class);
}
let classNames = labelOptions.class.split( /\s+/ );
classNames = classNames.map( className => Utils.sanitizeInput( className ) );
classNames.forEach(className => {
if ( false === Utils.validateClassName( className ) ) {
throw new Error('Invalid class name: ' + className);
}
});
labelOptions.class = classNames.join( ' ' );
this.#labelElement.className = labelOptions.class;
}
if (labelOptions.hasOwnProperty('id')) {
if (typeof labelOptions.id !== 'string') {
throw new Error('Invalid type for label id, must be of type string but found type: ' + typeof labelOptions.id);
}
labelOptions.id = Utils.sanitizeInput( labelOptions.id );
if (false === Utils.validateId( labelOptions.id )) {
throw new Error('Invalid id, cannot contain any kind of whitespace character and must be a valid CSS selector: ' + labelOptions.id);
}
this.#labelElement.id = labelOptions.id;
this.#domElement.setAttribute( 'aria-labelledby', this.#labelElement.id );
}
if ( labelOptions.hasOwnProperty( 'text' ) ) {
if ( typeof labelOptions.text !== 'string' ) {
throw new Error('Invalid type for label text, must be of type string but found type: ' + typeof labelOptions.text);
}
labelOptions.text = Utils.sanitizeInput( labelOptions.text );
this.#labelElement.textContent = labelOptions.text;
} else {
const locale = new Intl.Locale( this.#locale );
this.#labelElement.textContent = Messages[locale.language]['SELECT_A_CALENDAR'];
}
/*
if ( labelOptions.hasOwnProperty( 'after' ) ) {
if ( typeof labelOptions.after !== 'string' ) {
throw new Error('Invalid type for label after, must be of type string but found type: ' + typeof labelOptions.after);
}
}
*/
return this;
}
/**
* Sets the wrapper element for the calendar select element.
*
* The wrapper element is an HTML element that will wrap the select element.
* The wrapper element can be an HTML element of type `div` or `td`.
*
* If the `wrapperOptions` argument is not provided, the wrapper element will be set to `null`.
* If the `wrapperOptions` argument is provided but is not an object, an error will be thrown.
*
* The `wrapperOptions` object can contain the following properties:
* - `as`: The type of HTML element to use as the wrapper element.
* Must be one of `div` or `td`.
* If not provided, defaults to `div`.
* - `class`: The class attribute for the wrapper element.
* If not provided, no class will be set.
* - `id`: The id attribute for the wrapper element.
* If not provided, no id will be set.
*
* @param {object|null} [wrapperOptions=null]
* @returns {CalendarSelect}
* @throws {Error} If the `wrapperOptions` argument is not an object or is an array.
* @throws {Error} If the `wrapperOptions.as` property is not a string.
* @throws {Error} If the `wrapperOptions.as` property is not one of `div` or `td`.
* @throws {Error} If the `wrapperOptions.class` property is not a string.
* @throws {Error} If the `wrapperOptions.id` property is not a string.
* @throws {Error} If the `wrapperOptions.id` property is not a valid CSS selector.
*/
wrapper( wrapperOptions = null ) {
if ( this.#wrapperSet ) {
throw new Error('Wrapper has already been set on CalendarSelect instance with locale ' + this.#locale + '.');
}
if ( null === wrapperOptions ) {
this.#hasWrapper = false;
this.#wrapperElement = null;
this.#wrapperSet = true;
return this;
}
else if ( typeof wrapperOptions !== 'object' || Array.isArray(wrapperOptions) ) {
const wrapperOptionsType = Array.isArray(wrapperOptions) ? 'array' : typeof wrapperOptions;
throw new Error('Invalid type for wrapper options, must be of type object (not null or array) but found type: ' + wrapperOptionsType);
}
else if ( Object.keys( wrapperOptions ).length === 0 || false === Object.keys( wrapperOptions ).some( key => ['as', 'class', 'id'].includes( key ) ) ) {
throw new Error('Invalid wrapper options, must be an object with at least an `as`, `class` or `id` property');
}
if (wrapperOptions.hasOwnProperty('as')) {
if (typeof wrapperOptions.as !== 'string') {
throw new Error('Invalid type for wrapper `as` property, must be of type string but found type: ' + typeof wrapperOptions.as);
}
if (false === ['div', 'td'].includes(wrapperOptions.as)) {
throw new Error('Invalid value for wrapper `as` property, must be one of `div` or `td` but found: ' + wrapperOptions.as);
}
} else {
wrapperOptions.as = 'div';
}
this.#wrapperElement = document.createElement( wrapperOptions.as );
this.#hasWrapper = true;
this.#wrapperSet = true;
if ( wrapperOptions.hasOwnProperty( 'class' ) ) {
if ( typeof wrapperOptions.class !== 'string' ) {
throw new Error('Invalid type for wrapper class, must be of type string but found type: ' + typeof wrapperOptions.class);
}
let classNames = wrapperOptions.class.split( /\s+/ );
classNames = classNames.map( className => Utils.sanitizeInput( className ) );
classNames.forEach(className => {
if ( false === Utils.validateClassName( className ) ) {
throw new Error('Invalid class name: ' + className);
}
});
wrapperOptions.class = classNames.join( ' ' );
this.#wrapperElement.className = wrapperOptions.class;
}
if ( wrapperOptions.hasOwnProperty( 'id' ) ) {
if ( typeof wrapperOptions.id !== 'string' ) {
throw new Error('Invalid type for wrapper id, must be of type string but found type: ' + typeof wrapperOptions.id);
}
wrapperOptions.id = Utils.sanitizeInput( wrapperOptions.id );
if (false === Utils.validateId( wrapperOptions.id )) {
throw new Error('Invalid id, cannot contain any kind of whitespace character and must be a valid CSS selector: ' + wrapperOptions.id);
}
this.#wrapperElement.id = wrapperOptions.id;
}
return this;
}
/**
* Sets content to be inserted after the current element.
*
* This method allows appending content after the current element by creating
* a DocumentFragment from the provided content string. The content string is
* sanitized to remove PHP and script tags for security purposes. If no content
* is provided (null), it removes any previously set content. Throws an error
* if the method is called more than once since the content can only be set once.
*
* @param {string|null} contents - The content to be set after the current element.
* If null, any existing content is cleared.
* @throws {Error} If content is attempted to be set more than once.
* @returns {CalendarSelect} The current instance for method chaining.
*/
after( contents = null ) {
if ( this.#afterSet ) {
throw new Error('After has already been set.');
}
if ( null === contents ) {
this.#hasAfter = false;
this.#afterElement = null;
this.#afterSet = true;
return this;
}
// remove php tags and script tags from contents
// the regex is doing the following:
// - `<\?(?:php)?` matches the start of a php tag, optionally with the word "php" after the "?"
// - `|` is a logical OR operator
// - `\?>` matches the end of a php tag
// - `|` is a logical OR operator
// - `<script(.*?)>.*?<\/script>` matches the start of a script tag, any attributes, the contents of the script tag, and the end of the script tag
// - `g` flag makes the regex replacement global, so it will replace all occurrences of the regex, not just the first one
contents = contents.replace(/<\?(?:php)?|\?>|<script(.*?)>.*?<\/script>/g, '');
const fragment = document.createRange().createContextualFragment(contents);
this.#afterElement = fragment;
this.#hasAfter = true;
this.#afterSet = true;
return this;
}
/**
* Set whether the select element should include an empty option as the first option.
*
* If set to true, the select element will include an empty option as the first option.
* This can be useful when you want to allow the user to select no option.
* This also represents a value of "General Roman Calendar" for the API,
* since no national or diocesan calendar is selected.
* Selecting this empty value will enable the ApiOptions that can be set for the General Roman Calendar,
* but not for national or diocesan calendars, when an ApiOptions instance is listening to the current WebCalendar instance.
*
* If set to false, the select element will not include an empty option as the first option.
*
* If not provided, defaults to true.
*
* @param {boolean} [allowNull=true] - Whether the select element should include an empty option as the first option.
* @returns {CalendarSelect} The current instance for method chaining.
* @throws {Error} If allowNull has already been set on the CalendarSelect instance.
* @throws {Error} If the type of allowNull is not a boolean.
*/
allowNull( allowNull = true ) {
if ( typeof allowNull !== 'boolean' ) {
throw new Error('Invalid type for allowNull on CalendarSelect instance with locale ' + this.#locale + ', must be of type boolean but found type: ' + typeof allowNull);
}
this.#allowNull = allowNull;
this.filter( this.#filter );
return this;
}
/**
* Sets the disabled property on the select element.
*
* If set to true, the select element will be disabled and the user will not be able to interact with it.
* If set to false, the select element will be enabled and the user will be able to interact with it.
*
* If not provided, defaults to true.
*
* @param {boolean} [disabled=true] - Whether the select element should be disabled.
* @returns {CalendarSelect} The current instance for method chaining.
* @throws {Error} If the type of disabled is not a boolean.
*/
disabled( disabled = true ) {
if (typeof disabled !== 'boolean') {
throw new Error('Invalid type for disabled, must be of type boolean but found type: ' + typeof disabled);
}
this.#domElement.disabled = disabled;
return this;
}
/**
* Replaces the element matched by the provided element selector with the select element.
*
* If a wrapper element has been set, the wrapper element is used to replace the element,
* and the select element is appended to the wrapper element.
* If a label element has been set, the label element is inserted before the select element.
* If an after element has been set, the after element is inserted after the select element.
*
* @param {string|HTMLElement} element - The element or elector of the element to be replaced.
* @throws {Error} If the type of element is not a string.
* @throws {Error} If the element selector is invalid.
*/
replace( element ) {
let domNode;
if (typeof element === 'string') {
domNode = Utils.validateElementSelector( element );
}
else if (element instanceof HTMLElement) {
domNode = element;
} else {
throw new Error('CalendarSelect.replace: parameter must be a valid CSS selector or an instance of HTMLElement');
}
if ( this.#hasWrapper ) {
domNode.replaceWith( this.#wrapperElement );
this.#wrapperElement.appendChild( this.#domElement );
} else {
domNode.replaceWith( this.#domElement );
}
if ( this.#hasLabel ) {
this.#domElement.insertAdjacentElement( 'beforebegin', this.#labelElement );
}
if ( this.#hasAfter ) {
if (this.#domElement.parentNode) {
this.#domElement.parentNode.insertBefore( this.#afterElement, this.#domElement.nextSibling );
}
}
}
/**
* Appends the select element to the element matched by the provided element selector (or the element provided directly).
*
* If a wrapper element has been set, the wrapper element is used to append the select element,
* and the select element is appended to the wrapper element.
* If a label element has been set, the label element is inserted before the select element.
* If an after element has been set, the after element is inserted after the select element.
*
* @param {string|HTMLElement} element - The element selector of the element to append the select element to.
* @throws {Error} If the type of element is not a string.
* @throws {Error} If the element selector is invalid.
*/
appendTo( element ) {
let domNode;
if (typeof element === 'string') {
domNode = Utils.validateElementSelector( element );
}
else if (element instanceof HTMLElement) {
domNode = element;
} else {
throw new Error('CalendarSelect.appendTo: parameter must be a valid CSS selector or an instance of HTMLElement');
}
if ( this.#hasWrapper ) {
domNode.appendChild( this.#wrapperElement );
this.#wrapperElement.appendChild( this.#domElement );
} else {
domNode.appendChild( this.#domElement );
}
if ( this.#hasLabel ) {
this.#domElement.insertAdjacentElement( 'beforebegin', this.#labelElement );
}
if ( this.#hasAfter ) {
if (this.#domElement.parentNode) {
this.#domElement.parentNode.insertBefore( this.#afterElement, this.#domElement.nextSibling );
}
}
}
/**
* Inserts the select element before the element matched by the provided element selector (or the element provided directly).
*
* If a wrapper element has been set, the wrapper element is used to insert the select element,
* and the select element is appended to the wrapper element.
* If a label element has been set, the label element is inserted before the select element.
* If an after element has been set, the after element is inserted after the select element.
*
* @param {string|HTMLElement|Input} element - The element selector of the element to insert the select element before.
* @throws {Error} If the type of element is not a string.
* @throws {Error} If the element selector is invalid.
*/
insertBefore( element ) {
let domNode;
if (typeof element === 'string') {
domNode = Utils.validateElementSelector( element );
}
else if (element instanceof HTMLElement) {
domNode = element;
}
else if (element instanceof Input) {
if (element._hasWrapper) {
domNode = element._wrapperElement;
} else {
domNode = element._domElement;
}
}
else {
throw new Error('CalendarSelect.insertBefore: parameter must be a valid CSS selector or an instance of HTMLElement');
}
if ( this.#hasWrapper ) {
domNode.insertAdjacentElement( 'beforebegin', this.#wrapperElement );
this.#wrapperElement.appendChild( this.#domElement );
} else {
domNode.insertAdjacentElement( 'beforebegin', this.#domElement );
}
if ( this.#hasLabel ) {
this.#domElement.insertAdjacentElement( 'beforebegin', this.#labelElement );
}
if ( this.#hasAfter ) {
if (this.#domElement.parentNode) {
this.#domElement.parentNode.insertBefore( this.#afterElement, this.#domElement.nextSibling );
}
}
}
/**
* Inserts the select element after the element matched by the provided element selector (or the element provided directly).
*
* If a wrapper element has been set, the wrapper element is used to insert the select element,
* and the select element is appended to the wrapper element.
* If a label element has been set, the label element is inserted before the select element.
* If an after element has been set, the after element is inserted after the select element.
*
* @param {string|HTMLElement|Input} element - The element selector of the element to insert the select element after.
* @throws {Error} If the type of element is not a string.
* @throws {Error} If the element selector is invalid.
*/
insertAfter( element ) {
let domNode;
if (typeof element === 'string') {
console.log(`element is a CSS selector: ${element}`);
domNode = Utils.validateElementSelector( element );
}
else if (element instanceof HTMLElement) {
console.log(`element is an HTMLElement:`, element);
domNode = element;
}
else if (element instanceof Input) {
console.log(`element is an ApiOptions Input:`, element);
if (element._hasWrapper) {
console.log(`element has a wrapper element:`, element._wrapperElement);
domNode = element._wrapperElement;
} else {
domNode = element._domElement;
}
}
else {
throw new Error('CalendarSelect.insertAfter: parameter must be a valid CSS selector or an instance of HTMLElement');
}
console.log(`domNode:`, domNode);
if ( this.#hasWrapper ) {
domNode.insertAdjacentElement( 'afterend', this.#wrapperElement );
this.#wrapperElement.appendChild( this.#domElement );
} else {
domNode.insertAdjacentElement( 'afterend', this.#domElement );
}
if ( this.#hasLabel ) {
this.#domElement.insertAdjacentElement( 'beforebegin', this.#labelElement );
}
if ( this.#hasAfter ) {
if (this.#domElement.parentNode) {
this.#domElement.parentNode.insertBefore( this.#afterElement, this.#domElement.nextSibling );
}
}
}
/**
* Gets the underlying DOM element of the CalendarSelect instance.
*
* @returns {HTMLElement} The underlying DOM element of the CalendarSelect instance.
* @readonly
*/
get _domElement() {
return this.#domElement;
}
/**
* Gets the current filter of the CalendarSelect instance.
*
* The filter can be either `CalendarSelectFilter.NATIONAL_CALENDARS`, `CalendarSelectFilter.DIOCESAN_CALENDARS`, or `CalendarSelectFilter.NONE`.
* - `CalendarSelectFilter.NATIONAL_CALENDARS` will show only the nation options.
* - `CalendarSelectFilter.DIOCESAN_CALENDARS` will show only the diocese options grouped by nation.
* - `CalendarSelectFilter.NONE` will show all options, that is, both nation and diocese options.
*
* @returns {string} The current filter of the CalendarSelect instance.
* @readonly
*/
get _filter() {
return this.#filter;
}
/**
* Retrieves the status of whether a wrapper element has been set for the CalendarSelect instance.
*
* @returns {boolean} True if a wrapper element has been set; otherwise, false.
* @readonly
*/
get _hasWrapper() {
return this.#hasWrapper;
}
/**
* Gets the wrapper element for the CalendarSelect instance.
*
* The wrapper element is an HTML element that will wrap the select element.
* The wrapper element can be an HTML element of type `div` or `td`.
*
* If the `wrapperOptions` argument was not provided when calling the `wrapper` method,
* this will be `null`.
*
* @returns {HTMLElement|null} The wrapper element for the CalendarSelect instance, or `null` if no wrapper element was set.
* @readonly
*/
get _wrapperElement() {
return this.#wrapperElement;
}
/**
* Gets the status of whether the current CalendarSelect instance allows a null selected value.
*
* When `true`, the CalendarSelect instance will have an option for "None" or "No selection", and the `value` property
* will return `null` when this option is selected.
*
* When `false`, the CalendarSelect instance will not have an option for "None" or "No selection", and the `value` property
* will return an empty string when no option is selected.
*
* @returns {boolean} True if the current CalendarSelect instance allows a null selected value; otherwise, false.
* @readonly
*/
get _allowNull() {
return this.#allowNull;
}
/**
* Links the current `dioceses` filtered CalendarSelect instance to a `nations` filtered CalendarSelect instance.
* When the selected nation is changed in the linked `nations` filtered CalendarSelect instance, the diocese options
* of the current `dioceses` filtered CalendarSelect instance will be filtered accordingly.
* @param {CalendarSelect} calendarSelectInstance - The `nations` filtered CalendarSelect instance to link to the current `dioceses` filtered CalendarSelect instance.
* @returns {CalendarSelect} - The current `dioceses` filtered CalendarSelect instance.
* @throws {Error} If the current `dioceses` filtered CalendarSelect instance is already linked to another `nations` filtered CalendarSelect instance.
* @throws {Error} If the type of calendarSelectInstance is not a `CalendarSelect`.
* @throws {Error} If the filter of the current `dioceses` filtered CalendarSelect instance is not `dioceses`.
* @throws {Error} If the filter of the linked `nations` filtered CalendarSelect instance is not `nations`.
*/
linkToNationsSelect( calendarSelectInstance ) {
if (this.#linked) {
throw new Error('Current `dioceses` filtered CalendarSelect instance already linked to another `nations` filtered CalendarSelect instance');
}
if (false === calendarSelectInstance instanceof CalendarSelect) {
throw new Error('Invalid type for param