UNPKG

@digital-blueprint/lunchlottery-app

Version:

[GitHub Repository](https://github.com/digital-blueprint/lunchlottery-app) | [npmjs package](https://www.npmjs.com/package/@digital-blueprint/lunchlottery-app) | [Unpkg CDN](https://unpkg.com/browse/@digital-blueprint/lunchlottery-app/)

492 lines (434 loc) 17.9 kB
import {css, html} from 'lit'; import {ScopedElementsMixin} from '@dbp-toolkit/common'; import {DbpBaseElement} from '../base-element.js'; import {createRef, ref} from 'lit/directives/ref.js'; import {stringifyForDataValue} from '../utils.js'; import * as commonUtils from '@dbp-toolkit/common/utils'; import * as commonStyles from '@dbp-toolkit/common/styles'; import select2CSSPath from 'select2/dist/css/select2.min.css'; import $ from 'jquery'; import select2 from 'select2'; import select2LangDe from '../i18n/de/select2'; import select2LangEn from '../i18n/en/select2'; export class DbpEnumElement extends ScopedElementsMixin(DbpBaseElement) { constructor() { super(); // Generate a unique id per instance to avoid DOM id collisions across components this.formElementId = `form-element-${Math.random().toString(36).slice(2, 10)}`; this.label = ''; this.items = {}; this.multiple = false; // Since this.multiple === false, we set an empty string as this.value this.value = ''; this.dataValue = ''; this.displayMode = 'dropdown'; this.selectRef = createRef(); this.$select = null; select2(window, $); this.tagPlaceholder = null; } static get properties() { return { ...super.properties, multiple: {type: Boolean}, // We will treat this.value as an object and will not reflect it outside // Although we might write a string into this.value anyway for this.multiple === false value: {type: Object}, // That's the only thing we will reflect, and which will be used for gathering the data in the form dataValue: {type: String, attribute: 'data-value', reflect: true}, displayMode: {type: String, attribute: 'display-mode'}, items: {type: Object}, tagPlaceholder: {type: Object}, }; } $(selector) { return $(this.shadowRoot.querySelector(selector)); } closeSelect2() { if (this.select2IsInitialized()) { this.$select.select2('close'); } } // Can be used to set items from the outside setItems(items) { this.items = items; } isValueEmptyArray() { return Array.isArray(this.value) && this.value.length === 0; } isValueEmpty() { return this.value === '' || this.isValueEmptyArray(); } connectedCallback() { super.connectedCallback(); this.updateComplete.then(() => { this.$select = this.$('#' + this.formElementId); this.initSelect2IfNeeded(); }); } isDisplayModeTags() { return ['tags', 'tag'].includes(this.displayMode); } initSelect2IfNeeded() { if (this.isDisplayModeTags() && !this.select2IsInitialized()) { this.initSelect2(); } } select2IsInitialized() { return this.$select !== null && this.$select.hasClass('select2-hidden-accessible'); } /** * Initializes the Select2 selector * * @param ignorePreset */ initSelect2(ignorePreset = false) { if (this.$select === null) { return false; } // we need to destroy Select2 and remove the event listeners before we can initialize it again if (this.$select && this.$select.hasClass('select2-hidden-accessible')) { this.$select.select2('destroy'); this.$select.off('select2:select'); this.$select.off('select2:closing'); } this.$select .select2({ width: '100%', language: this.lang === 'de' ? select2LangDe() : select2LangEn(), allowClear: true, placeholder: this.tagPlaceholder?.[this.lang] || this._i18n.t('render-form.enum.select-placeholder'), dropdownParent: this.$('#select-dropdown'), data: commonUtils.keyValueObjectToSelect2DataArray(this.items), }) // https://select2.org/programmatic-control/events // select2:clear will trigger select2:unselect and change for each selected item .on('change', this.handleInputValue.bind(this)); // Set the value after initialization this.$select.val(this.value).trigger('change'); return true; } render() { if (this.hidden) { return html``; } // Regenerate error messages in case the language has changed this.handleErrorsIfAny(); // Check if the label slot has any assigned content const hasLabelSlot = this.querySelector('[slot="label"]') !== null; return html` <fieldset> <label for="${this.formElementId}"> <slot name="label">${this.label}</slot> ${this.required && (hasLabelSlot || this.label) ? html` <span class="required-mark"> ${this._i18n.t('render-form.base-object.required-field')} </span> ` : html``} </label> ${this.description ? html` <div class="description"> ${this.description} ${this.required ? html` ${this._i18n.t('render-form.base-object.required-field')} ` : html``} </div> ` : ''} ${this.renderErrorMessages()} ${this.renderInput()} </fieldset> `; } renderInput() { const validModes = ['dropdown', 'list', 'tag', 'tags']; if (!validModes.includes(this.displayMode)) { console.warn(`Invalid display-mode: ${this.displayMode}. Defaulting to 'dropdown'.`); this._displayMode = 'dropdown'; } else { this._displayMode = this.displayMode; } // In case it wasn't handled before this.handleEmptyValue(); const select2CSS = commonUtils.getAbsoluteURL(select2CSSPath); switch (this._displayMode) { case 'dropdown': // If multiple is true, this.value is an array of selected values! return html` <select ${ref(this.selectRef)} id="${this.formElementId}" name="${this.name}" @change="${this.handleInputValue}" ?disabled=${this.disabled} ?required=${this.required} ?multiple=${this.multiple}> ${Object.keys(this.items).map( (key) => html` <option value="${key}" ?selected=${this.multiple ? this.value?.includes(key) : key === this.value}> ${this.items[key]} </option> `, )} </select> `; case 'list': return html` ${Object.keys(this.items).map( (key) => html` <label class="checkboxItem"> ${this.multiple ? html` <input type="checkbox" id="${this.formElementId}" name="${this.name}" value="${key}" class="checkbox" ?checked="${!!this.value?.includes(key)}" @input="${this.handleInputValue}" ?disabled=${this.disabled} ?required=${this.required} /> ` : html` <input type="radio" id="${this.formElementId}" name="${this.name}" value="${key}" class="radio" ?checked="${!!this.value?.includes(key)}" @input="${this.handleInputValue}" ?disabled=${this.disabled} ?required=${this.required} /> `} ${this.items[key]} </label> `, )} `; case 'tag': case 'tags': return html` <link rel="stylesheet" href="${select2CSS}" /> <select ${ref(this.selectRef)} id="${this.formElementId}" name="${this.name}" class="select" ?disabled=${this.disabled} ?required=${this.required} ?multiple=${this.multiple}></select> <div id="select-dropdown"></div> `; default: console.warn( `Unsupported display mode: ${this._displayMode}. Defaulting to 'dropdown'.`, ); break; } } handleEmptyValue() { // If the value for a single-select dropdown is empty, then show either the first item // or the item for the empty value if (this._displayMode === 'dropdown' && !this.multiple && this.value === '') { const emptyItem = this.items['']; this.value = emptyItem ? emptyItem : Object.keys(this.items)[0]; this.generateDataValue(); } // For this.multiple === true and empty value, fix the value and dataValue if necessary if (this.isValueEmpty() && !!this.multiple && this.dataValue !== '[]') { if (!this.isValueEmptyArray()) { this.value = []; } this.generateDataValue(); } } static get styles() { return [ ...super.styles, commonStyles.getSelect2CSS(), // language=css css` :host([layout-type='inline']) fieldset { display: flex; gap: var(--dbp-enum-label-gap, 1em); margin: 0; align-items: center; } :host([layout-type='inline']) label { white-space: nowrap; margin-bottom: 0; } /* allows .select2-container to fully expand */ :host([layout-type='inline']) #select-dropdown { order: 1; } :host([layout-type='inline']) .select2 { order: 2; } :host([layout-type='inline']) .checkboxItem:not(:last-of-type) { margin-bottom: 0; } /* For some reasons the selector chevron was very large */ select:not(.select) { background-size: 1em; } :host([multiple]) select:not(.select) { background: none; } label a { text-decoration: underline; } .checkboxItem { display: flex; align-items: center; column-gap: 4px; width: fit-content; line-height: 1; cursor: pointer; font-weight: normal; } .checkboxItem:not(:last-of-type) { margin-bottom: 16px; } .checkbox { appearance: none; position: relative; width: 20px; height: 20px; border: 1px solid var(--dbp-info-surface); border-radius: 2px; cursor: pointer; margin-left: 0; } .checkbox:checked { background-color: var(--dbp-info-surface); } .checkbox:checked::after { content: ''; position: absolute; top: 5px; left: 3px; width: 12px; height: 6px; border-bottom: 2px solid #ffffff; border-left: 2px solid #ffffff; transform: rotate(-45deg); } .required-mark { color: var(--dbp-danger); } #select-dropdown { position: relative; } .select2-control.control { width: 100%; } .select2-container--default .select2-selection--multiple .select2-selection__choice { border-radius: 0; } .select2-container--default .select2-selection--multiple { border-radius: 0; border-color: var(--dbp-override-content); } .select2-container--default .select2-search--inline .select2-search__field { /* Needed for the placeholder to be visible. The width of the input is set to 0 by js. */ width: min-content !important; } `, ]; } handleInputValue(e) { // Only handle user-triggered events and events from Select2 in tag mode if (e.isTrusted !== true && !this.isDisplayModeTags()) return; if (this.displayMode === 'dropdown') { this.value = this.multiple ? Array.from(e.target.selectedOptions).map((option) => option.value) : e.target.value; } if (this.displayMode === 'list') { this.value = this.multiple ? Array.from(this._a('[type="checkbox"]')) .filter((checkbox) => checkbox.checked) .map((checkbox) => checkbox.value) : e.target.value; } if (this.isDisplayModeTags()) { this.value = this.$select.val(); } this.generateDataValue(); // Pass the value and field name in the event detail const changeEvent = new CustomEvent('change', { detail: {value: this.value, fieldName: this.name}, bubbles: true, composed: true, }); this.dispatchEvent(changeEvent); } adaptValueForMultiple() { // if (!this.value) { // // this.value = this.multiple ? [] : Object.keys(this.items)[0]; // this.value = this.multiple ? [] : ''; // } if (this.multiple && !Array.isArray(this.value)) { // Convert single value to an array if switching to multiple mode this.value = [this.value]; } else if (!this.multiple && Array.isArray(this.value)) { // Convert array to a single value if switching to single mode this.value = this.value[0] || ''; } } update(changedProperties) { changedProperties.forEach((oldValue, propName) => { switch (propName) { // Disabled, because it causes race conditions! // case 'items': // case 'multiple': // this.adaptValueForMultiple(); // break; case 'displayMode': this.initSelect2IfNeeded(); break; case 'items': this.handleEmptyValue(); // If the display mode is tags, we need to reinitialize Select2 to get in the new items if (this.isDisplayModeTags()) { this.initSelect2(); } break; case 'multiple': this.handleEmptyValue(); break; case 'value': { this.handleEmptyValue(); this.generateDataValue(); if (this.select2IsInitialized()) { this.$select.val(this.value).trigger('change'); } break; } } }); super.update(changedProperties); } generateDataValue() { if (this.multiple) { this.dataValue = stringifyForDataValue(this.value); } else { this.dataValue = this.value; } } }