UNPKG

@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,006 lines 50.3 kB
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; /** * 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` DOM input. * - `class`: The class name for the `CalendarSelect` DOM input. * - `name`: The name for the `CalendarSelect` DOM input. * - `filter`: The `CalendarSelectFilter` to apply to the `CalendarSelect` component. * - `after`: an html string to append after the `CalendarSelect` element. * - `allowNull`: a boolean to indicate whether the `CalendarSelect` element should allow `null` values. * - `disabled`: a boolean to indicate whether the `CalendarSelect` DOM input element should be disabled. * - `label`: The label for the `CalendarSelect` DOM input (an object with a `text` property, and optionally `class` and `id` properties). * - `wrapper`: The wrapper for the `CalendarSelect` component (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 ISO 639-1 code 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 || typeof options === 'undefined') { 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: inputLocale, id, name, filter, after, label, wrapper, allowNull, disabled } = options; if (inputLocale !== undefined && inputLocale !== null) { if (typeof inputLocale !== 'string') { throw new Error('Invalid type for locale, must be of type `string` but found type: ' + typeof inputLocale); } let locale = inputLocale; 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'; try { this.#countryNames = new Intl.DisplayNames([this.#locale], { type: 'region' }); } catch (e) { throw new Error('Failed to initialize locale: ' + this.#locale); } } 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` DOM input. * * 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; } /** * Gets or sets the selected value of the CalendarSelect. * * When called without arguments, returns the current selected value. * When called with a value argument, sets the selected value and returns the instance for chaining. * * @param {string} [val] - The value to set. If omitted, the method acts as a getter. * @returns {string|CalendarSelect} The current value when used as getter, or the instance when used as setter. * @throws {Error} If the provided value is not a string. */ value(val) { if (typeof val === 'undefined') { return this.#domElement.value; } if (typeof val !== 'string') { throw new Error('Invalid type for value, must be of type string but found type: ' + typeof val); } this.#domElement.value = val; return this; } /** * Registers a callback function to be called when the selected value changes. * * @param {Function} callback - The callback function to execute on change. * Receives the change event as its argument. * @returns {CalendarSelect} The current instance for method chaining. * @throws {Error} If the callback is not a function. */ onChange(callback) { if (typeof callback !== 'function') { throw new Error('Invalid type for onChange callback, must be of type function but found type: ' + typeof callback); } this.#domElement.addEventListener('change', callback); 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 parameter passed to linkToNationsSelect, must be of type `CalendarSelect` but found type: ' + typeof calendarSelectInstance); } if (this.#filter !== CalendarSelectFilter.DIOCESAN_CALENDARS) { throw new Error('Can only link a `CalendarSelectFilter.DIOCESAN_CALENDARS` filtered CalendarSelect instance to a `CalendarSelectFilter.NATIONAL_CALENDARS` filtered CalendarSelect instance. Instead of expected `dioceses` filter, found filter: ' + this.#filter); } if (calendarSelectInstance._filter !== CalendarSelectFilter.NATIONAL_CALENDARS) { throw new Error('Can only link a `CalendarSelectFilter.DIOCESAN_CALENDARS` filtered CalendarSelect instance to a `CalendarSelectFilter.NATIONAL_CALENDARS` filtered CalendarSelect instance. Instead of expected `nations` filter for the linked CalendarSelect instance, found filter: ' + calendarSelectInstance._filter); } const linkedDomElement = calendarSelectInstance._domElement; this.#f