mirror of
https://github.com/EGroupware/egroupware.git
synced 2024-11-25 09:23:28 +01:00
Merge remote-tracking branch 'origin/master'
This commit is contained in:
commit
e3ca0bfacd
@ -20,6 +20,7 @@ import {egwAction, egwActionObject} from '../../api/js/egw_action/egw_action';
|
||||
import {LitElement} from "@lion/core";
|
||||
import {et2_nextmatch} from "../../api/js/etemplate/et2_extension_nextmatch";
|
||||
import {et2_DOMWidget} from "../../api/js/etemplate/et2_core_DOMWidget";
|
||||
import {Et2SelectAccount} from "../../api/js/etemplate/Et2Select/Et2SelectAccount";
|
||||
|
||||
/**
|
||||
* UI for Admin
|
||||
@ -710,8 +711,7 @@ class AdminApp extends EgwApp
|
||||
sel_options.acl_appname = [];
|
||||
for(let app in acl_rights)
|
||||
{
|
||||
sel_options.acl_appname.push({value: app, label: this.egw.lang(
|
||||
<string>this.egw.link_get_registry(app, 'entries') || app)});
|
||||
sel_options.acl_appname.push({value: app, label: app});
|
||||
}
|
||||
// Sort list
|
||||
sel_options.acl_appname.sort(function(a,b) {
|
||||
@ -1367,6 +1367,28 @@ class AdminApp extends EgwApp
|
||||
if (use_default) use_default.set_value(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* onchange callback for mail account account_id (valid for)
|
||||
*
|
||||
* @param {object} _event
|
||||
* @param {et2_widget} _widget
|
||||
*/
|
||||
warnMailAccountForAllChanged(_event : Event, _widget : Et2SelectAccount)
|
||||
{
|
||||
const account_id = _widget.value;
|
||||
const old_account_id = this.et2.getArrayMgr('content').getEntry('account_id');
|
||||
|
||||
// this is (no longer) an account for all
|
||||
if ((Array.isArray(account_id) ? account_id.length : account_id) &&
|
||||
// but this was an account for all
|
||||
!(Array.isArray(old_account_id) ? old_account_id.length : old_account_id))
|
||||
{
|
||||
_widget.blur();
|
||||
Et2Dialog.alert(this.egw.lang('By selecting a user or group you effectively delete the mail account for all other users!\n\nAre you really sure you want to do that?'),
|
||||
this.egw.lang('This is a mail account for ALL users!'), Et2Dialog.WARNING_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* default onExecute for admin actions
|
||||
*
|
||||
|
@ -188,6 +188,7 @@ blocking after wrong password admin de Blockierung nach falschem Passwort
|
||||
bottom admin de unten
|
||||
bulk password reset admin de Rücksetzen mehrerer Passwörter
|
||||
by admin de Von
|
||||
by selecting a user or group you effectively delete the mail account for all other users!\n\nare you really sure you want to do that? admin de Durch die Auswahl eines Benutzer oder Gruppe löschen Sie das Mailkonto für alle anderen Benutzer!\n\nSind Sie wirklich sicher, dass Sie das wollen?
|
||||
calculate next run admin de nächste Ausführung berechnen
|
||||
calendar recurrence horizont in days (default 1000) admin de Kalender Wiederholungs-Bereich in Tagen (Vorgabe sind 1000)
|
||||
can be used by application admin de Kann von folgender Anwendung verwendet werden
|
||||
@ -920,6 +921,7 @@ this application is current admin de Diese Anwendung ist aktuell
|
||||
this application requires an upgrade admin de Diese Anwendung benötigt ein Upgrade
|
||||
this category is currently being used by applications as a parent category admin de Diese Kategorie wird gegenwärtig als übergeordnete Kategorie benutzt.
|
||||
this controls exports and merging. admin de Steuert den Export und den Merge Print von Dokumenten
|
||||
this is a mail account for all users! admin de Das ist ein Mailkonto für ALLE Benutzer!
|
||||
this is not a personal mail account!\n\naccount will be deleted for all users!\n\nare you really sure you want to do that? admin de Das ist KEIN persönliches Mail-Konto!\n\nDas Konto wird für ALLE Benutzer gelöscht!\n\nSind Sie wirklich sicher, dass Sie das wollen?
|
||||
this key used in the html code your site serves to users. admin de Dieser Schlüssel steht im HTML Code den Ihre Website ausliefert.
|
||||
this php has no imap support compiled in!! admin de Dieses PHP hat keine IMAP Unterstützung!!!
|
||||
|
@ -188,6 +188,7 @@ blocking after wrong password admin en Blocking after wrong password
|
||||
bottom admin en Bottom
|
||||
bulk password reset admin en Bulk password reset
|
||||
by admin en By
|
||||
by selecting a user or group you effectively delete the mail account for all other users!\n\nare you really sure you want to do that? admin en By selecting a user or group you effectively delete the mail account for all other users!\n\nAre you really sure you want to do that?
|
||||
calculate next run admin en Calculate next run
|
||||
calendar recurrence horizont in days (default 1000) admin en Calendar recurrence horizon in days. Default = 1000.
|
||||
can be used by application admin en Can be used by application
|
||||
@ -923,6 +924,7 @@ this application is current admin en This application is current.
|
||||
this application requires an upgrade admin en This application requires an upgrade.
|
||||
this category is currently being used by applications as a parent category admin en This category is currently being used by applications as a parent category.
|
||||
this controls exports and merging. admin en This controls exports and merge prints.
|
||||
this is a mail account for all users! admin en This is a mail account for ALL users!
|
||||
this is not a personal mail account!\n\naccount will be deleted for all users!\n\nare you really sure you want to do that? admin en This is NOT a personal mail account!\n\nAccount will be deleted for ALL users!\n\nAre you really sure you want to do that?
|
||||
this key used in the html code your site serves to users. admin en This key used in the HTML code your site serves to users.
|
||||
this php has no imap support compiled in!! admin en This PHP has no IMAP support compiled in!!
|
||||
|
@ -341,7 +341,7 @@
|
||||
<row class="emailadmin_no_user dialogHeader2">
|
||||
<et2-description for="account_id" value="Valid for"></et2-description>
|
||||
<et2-hbox span="all">
|
||||
<et2-select-account id="account_id" placeholder="Everyone" multiple="true" accountType="both" rows="1"></et2-select-account>
|
||||
<et2-select-account id="account_id" placeholder="Everyone" multiple="true" accountType="both" rows="1" onchange="app.admin.warnMailAccountForAllChanged"></et2-select-account>
|
||||
<et2-checkbox label="account editable by user" id="acc_user_editable"></et2-checkbox>
|
||||
</et2-hbox>
|
||||
</row>
|
||||
|
@ -611,14 +611,14 @@ export class Et2DateDuration extends Et2InputWidget(FormControlMixin(LitElement)
|
||||
};
|
||||
// It would be nice to use an et2-select here, but something goes weird with the styling
|
||||
return html`
|
||||
<et2-select value="${this._display.unit || this.displayFormat[0]}">
|
||||
<sl-select value="${this._display.unit || this.displayFormat[0]}">
|
||||
${[...this.displayFormat].map((format : string) =>
|
||||
html`
|
||||
<sl-option value=${format}>
|
||||
${this.time_formats[format]}
|
||||
</sl-option>`
|
||||
)}
|
||||
</et2-select>
|
||||
</sl-select>
|
||||
`;
|
||||
}
|
||||
|
||||
|
@ -131,10 +131,10 @@ export class Et2DropdownButton extends Et2WidgetWithSelectMixin(LitElement)
|
||||
<et2-image slot="prefix" src=${option.icon} icon></et2-image>` : '';
|
||||
|
||||
return html`
|
||||
<sl-option value="${option.value}">
|
||||
<sl-menu-item value="${option.value}">
|
||||
${icon}
|
||||
${this.noLang ? option.label : this.egw().lang(option.label)}
|
||||
</sl-option>`;
|
||||
</sl-menu-item>`;
|
||||
}
|
||||
|
||||
protected _handleSelect(ev)
|
||||
|
@ -489,7 +489,9 @@ const Et2InputWidgetMixin = <T extends Constructor<LitElement>>(superclass : T)
|
||||
|
||||
// Set attributes for the form / autofill. It's the individual widget's
|
||||
// responsibility to do something appropriate with these properties.
|
||||
if(this.autocomplete == "on" && window.customElements.get(this.localName).getPropertyOptions("name") != "undefined")
|
||||
if(this.autocomplete == "on" && window.customElements.get(this.localName).getPropertyOptions("name") != "undefined" &&
|
||||
this.getArrayMgr("content") !== null
|
||||
)
|
||||
{
|
||||
this.name = this.getArrayMgr("content").explodeKey(this.id).pop();
|
||||
}
|
||||
|
@ -82,11 +82,21 @@ export function inputBasicTests(before : Function, test_value : string, value_se
|
||||
{
|
||||
element = await before();
|
||||
});
|
||||
it("no value gives empty string", () =>
|
||||
it("no value gives empty string", async() =>
|
||||
{
|
||||
element.set_value("");
|
||||
await elementUpdated(element);
|
||||
|
||||
// Shows as empty / no value
|
||||
let value = (<Element><unknown>element).querySelector(value_selector) || (<Element><unknown>element).shadowRoot.querySelector(value_selector);
|
||||
assert.isDefined(value, "Bad value selector '" + value_selector + "'");
|
||||
debugger;
|
||||
assert.equal(value.textContent.trim(), "", "Displaying something when there is no value");
|
||||
if(element.multiple)
|
||||
{
|
||||
assert.isEmpty(element.get_value());
|
||||
return;
|
||||
}
|
||||
// Gives no value
|
||||
assert.equal(element.get_value(), "", "Value mismatch");
|
||||
});
|
||||
@ -94,7 +104,7 @@ export function inputBasicTests(before : Function, test_value : string, value_se
|
||||
it("value out matches value in", async() =>
|
||||
{
|
||||
element.set_value(test_value);
|
||||
|
||||
debugger;
|
||||
// wait for asychronous changes to the DOM
|
||||
await elementUpdated(<Element><unknown>element);
|
||||
|
||||
|
@ -87,8 +87,8 @@ export class Et2LinkSearch extends Et2Select
|
||||
|
||||
// Set a value we don't have as an option? That's OK, we'll just add it
|
||||
if(changedProperties.has("value") && this.value && this.value.length > 0 && (
|
||||
this.getAllOptions().length == 0 ||
|
||||
this.getAllOptions().filter && this.getAllOptions().filter(item => this.getValueAsArray().includes(item.value)).length == 0
|
||||
this.select_options.length == 0 ||
|
||||
this.select_options.filter && this.select_options.filter(item => this.getValueAsArray().includes(item.value)).length == 0
|
||||
))
|
||||
{
|
||||
this._missingOption(this.value)
|
||||
|
@ -141,7 +141,7 @@ export class Et2LinkTo extends Et2InputWidget(ScopedElementsMixin(FormControlMix
|
||||
<et2-link-entry .onlyApp="${this.onlyApp}"
|
||||
.applicationList="${this.applicationList}"
|
||||
.readonly=${this.readonly}
|
||||
@sl-select=${this.handleEntrySelected}
|
||||
@sl-change=${this.handleEntrySelected}
|
||||
@sl-clear="${this.handleEntryCleared}">
|
||||
</et2-link-entry>
|
||||
<et2-button id="link_button" label="Link" class="link" .noSubmit=${true}
|
||||
@ -405,6 +405,7 @@ export class Et2LinkTo extends Et2InputWidget(ScopedElementsMixin(FormControlMix
|
||||
if(event.target == this.select._searchNode)
|
||||
{
|
||||
this.classList.add("can_link");
|
||||
this.link_button.focus();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -45,6 +45,10 @@ export class Et2ColumnSelection extends Et2InputWidget(LitElement)
|
||||
background-repeat: no-repeat;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
sl-menu-item::part(label), sl-menu-item::part(submenu-icon) {
|
||||
cursor: initial;
|
||||
}
|
||||
/* Change vertical alignment of CF checkbox line to up with title, not middle */
|
||||
.custom_fields::part(base) {
|
||||
align-items: baseline;
|
||||
@ -75,19 +79,17 @@ export class Et2ColumnSelection extends Et2InputWidget(LitElement)
|
||||
{
|
||||
super(...args);
|
||||
|
||||
this.columnClickHandler = this.columnClickHandler.bind(this);
|
||||
this.handleSelectAll = this.handleSelectAll.bind(this);
|
||||
}
|
||||
|
||||
connectedCallback()
|
||||
{
|
||||
super.connectedCallback();
|
||||
|
||||
this.updateComplete.then(() =>
|
||||
{
|
||||
this.sort = Sortable.create(this.shadowRoot.querySelector('sl-menu'), {
|
||||
ghostClass: 'ui-fav-sortable-placeholder',
|
||||
draggable: 'sl-option.column',
|
||||
draggable: 'sl-menu-item.column',
|
||||
dataIdAttr: 'value',
|
||||
direction: 'vertical',
|
||||
delay: 25
|
||||
@ -101,7 +103,7 @@ export class Et2ColumnSelection extends Et2InputWidget(LitElement)
|
||||
<sl-icon slot="header" name="check-all" @click=${this.handleSelectAll}
|
||||
title="${this.egw().lang("Select all")}"
|
||||
style="font-size:24px"></sl-icon>
|
||||
<sl-menu @sl-select="${this.columnClickHandler}" part="columns" slot="content">
|
||||
<sl-menu part="columns" slot="content">
|
||||
${repeat(this.__columns, (column) => column.id, (column) => this.rowTemplate(column))}
|
||||
</sl-menu>`;
|
||||
}
|
||||
@ -143,11 +145,11 @@ export class Et2ColumnSelection extends Et2InputWidget(LitElement)
|
||||
return html``;
|
||||
}
|
||||
return html`
|
||||
<sl-option
|
||||
value="${column.id}"
|
||||
<sl-menu-item
|
||||
value="${column.id.replaceAll(" ", "___")}"
|
||||
type="checkbox"
|
||||
?checked=${alwaysOn || column.visibility == et2_dataview_column.ET2_COL_VISIBILITY_VISIBLE}
|
||||
?disabled=${alwaysOn}
|
||||
.selected="${this.value.some(v => v == column.id)}"
|
||||
title="${column.title}"
|
||||
class="${classMap({
|
||||
select_row: true,
|
||||
@ -157,7 +159,7 @@ export class Et2ColumnSelection extends Et2InputWidget(LitElement)
|
||||
${column.caption}
|
||||
<!-- Custom fields get listed separately -->
|
||||
${isCustom ? this.customFieldsTemplate(column) : ''}
|
||||
</sl-option>`;
|
||||
</sl-menu-item>`;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -191,18 +193,10 @@ export class Et2ColumnSelection extends Et2InputWidget(LitElement)
|
||||
<sl-divider></sl-divider>`;
|
||||
}
|
||||
|
||||
columnClickHandler(event)
|
||||
{
|
||||
const item = event.detail.item;
|
||||
|
||||
// Toggle checked state
|
||||
item.checked = !item.checked;
|
||||
}
|
||||
|
||||
handleSelectAll(event)
|
||||
{
|
||||
let checked = (<SlMenuItem>this.shadowRoot.querySelector("sl-option")).checked || false;
|
||||
this.shadowRoot.querySelectorAll('sl-option').forEach((item) => {item.checked = !checked});
|
||||
let checked = (<SlMenuItem>this.shadowRoot.querySelector("sl-menu-item")).checked || false;
|
||||
this.shadowRoot.querySelectorAll('sl-menu-item').forEach((item) => {item.checked = !checked});
|
||||
}
|
||||
|
||||
set columns(new_columns)
|
||||
@ -229,7 +223,7 @@ export class Et2ColumnSelection extends Et2InputWidget(LitElement)
|
||||
{
|
||||
menuItem.querySelectorAll("[value][checked]").forEach((cf : SlMenuItem) =>
|
||||
{
|
||||
value.push(cf.value);
|
||||
value.push(cf.value.replaceAll("___", " "));
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -14,10 +14,9 @@ import {Et2WidgetWithSelectMixin} from "./Et2WidgetWithSelectMixin";
|
||||
import {SelectOption} from "./FindSelectOptions";
|
||||
import shoelace from "../Styles/shoelace";
|
||||
import {RowLimitedMixin} from "../Layout/RowLimitedMixin";
|
||||
import {Et2Tag} from "./Tag/Et2Tag";
|
||||
import {Et2WithSearchMixin} from "./SearchMixin";
|
||||
import {property} from "lit/decorators/property.js";
|
||||
import {SlChangeEvent, SlSelect} from "@shoelace-style/shoelace";
|
||||
import {SlChangeEvent, SlOption, SlSelect} from "@shoelace-style/shoelace";
|
||||
import {repeat} from "lit/directives/repeat.js";
|
||||
|
||||
// export Et2WidgetWithSelect which is used as type in other modules
|
||||
@ -144,13 +143,13 @@ export class Et2Select extends Et2WithSearchMixin(Et2WidgetWithSelect)
|
||||
|
||||
/* Hide dropdown trigger when multiple & readonly */
|
||||
|
||||
:host([readonly][multiple]) .select__expand-icon {
|
||||
:host([readonly][multiple])::part(expand-icon) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Style for tag count if rows=1 */
|
||||
|
||||
:host([readonly][multiple][rows]) .select__tags sl-tag {
|
||||
:host([readonly][multiple][rows])::part(tags) {
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
top: 1px;
|
||||
@ -196,6 +195,11 @@ export class Et2Select extends Et2WithSearchMixin(Et2WidgetWithSelect)
|
||||
max-height: 8em;
|
||||
overflow-y: auto;
|
||||
}
|
||||
:host([readonly])::part(combobox) {
|
||||
background: none;
|
||||
opacity: 1;
|
||||
border: none;
|
||||
}
|
||||
`
|
||||
];
|
||||
}
|
||||
@ -293,19 +297,88 @@ export class Et2Select extends Et2WithSearchMixin(Et2WidgetWithSelect)
|
||||
|
||||
_triggerChange(e)
|
||||
{
|
||||
if(super._triggerChange(e) && !this._block_change_event)
|
||||
if(super._triggerChange(e))
|
||||
{
|
||||
this.dispatchEvent(new Event("change", {bubbles: true}));
|
||||
}
|
||||
if(this._block_change_event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the case where there is no value set, or the value provided is not an option.
|
||||
* If this happens, we choose the first option or empty label.
|
||||
*
|
||||
* Careful when this is called. We change the value here, so an infinite loop is possible if the widget has
|
||||
* onchange.
|
||||
*
|
||||
*/
|
||||
protected fix_bad_value()
|
||||
{
|
||||
// Stop if there are no options
|
||||
if(!Array.isArray(this.select_options) || this.select_options.length == 0)
|
||||
{
|
||||
this.updateComplete.then(() => this._block_change_event = false);
|
||||
// Nothing to do here
|
||||
return;
|
||||
}
|
||||
|
||||
// emptyLabel is fine
|
||||
if(this.value === "" && this.emptyLabel)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let valueArray = this.getValueAsArray();
|
||||
|
||||
// Check for value using missing options (deleted or otherwise not allowed)
|
||||
let filtered = this.filterOutMissingOptions(valueArray);
|
||||
if(filtered.length != valueArray.length)
|
||||
{
|
||||
this.value = filtered;
|
||||
return;
|
||||
}
|
||||
|
||||
// Multiple is allowed to be empty, and if we don't have an emptyLabel or options nothing to do
|
||||
if(this.multiple || (!this.emptyLabel && this.select_options.length === 0))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// See if parent (search / free entry) is OK with it
|
||||
if(super.fix_bad_value())
|
||||
{
|
||||
return;
|
||||
}
|
||||
// If somebody gave '' as a select_option, let it be
|
||||
if(this.value === '' && this.select_options.filter((option) => this.value === option.value).length == 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// If no value is set, choose the first option
|
||||
// Only do this on once during initial setup, or it can be impossible to clear the value
|
||||
|
||||
// value not in options --> use emptyLabel, if exists, or first option otherwise
|
||||
if(this.select_options.filter((option) => valueArray.find(val => val == option.value) ||
|
||||
Array.isArray(option.value) && option.value.filter(o => valueArray.find(val => val == o.value))).length === 0)
|
||||
{
|
||||
let oldValue = this.value;
|
||||
this.value = this.emptyLabel ? "" : "" + this.select_options[0]?.value;
|
||||
// ""+ to cast value of 0 to "0", to not replace with ""
|
||||
this.requestUpdate("value", oldValue);
|
||||
}
|
||||
}
|
||||
|
||||
@property()
|
||||
get value()
|
||||
{
|
||||
// Handle a bunch of non-values, if it's multiple we want an array
|
||||
if(this.multiple && (this.__value == "null" || this.__value == null || typeof this.__value == "undefined" ||
|
||||
!this.emptyLabel && this.__value == "" && !this.select_options.find(o => o.value == "")))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
if(!this.multiple && !this.emptyLabel && this.__value == "" && !this.select_options.find(o => o.value == ""))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return this.multiple ?
|
||||
this.__value ?? [] :
|
||||
this.__value ?? "";
|
||||
@ -384,10 +457,17 @@ export class Et2Select extends Et2WithSearchMixin(Et2WidgetWithSelect)
|
||||
return filteredArray;
|
||||
}
|
||||
|
||||
// Empty is allowed, if there's an emptyLabel
|
||||
if(value.toString() == "" && this.emptyLabel)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
const missing = filterBySelectOptions(value, this.select_options);
|
||||
if(missing.length > 0)
|
||||
{
|
||||
console.warn("Invalid option '" + missing.join(", ") + " ' removed");
|
||||
debugger;
|
||||
console.warn("Invalid option '" + missing.join(", ") + "' removed from " + this.id, this);
|
||||
value = value.filter(item => missing.indexOf(item) == -1);
|
||||
}
|
||||
}
|
||||
@ -395,9 +475,9 @@ export class Et2Select extends Et2WithSearchMixin(Et2WidgetWithSelect)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an option for the "empty label" option, used if there's no value
|
||||
* Additional customisations from the XET node
|
||||
*
|
||||
* @returns {TemplateResult}
|
||||
* @param {Element} _node
|
||||
*/
|
||||
loadFromXML(_node : Element)
|
||||
{
|
||||
@ -484,59 +564,6 @@ export class Et2Select extends Et2WithSearchMixin(Et2WidgetWithSelect)
|
||||
}
|
||||
*/
|
||||
|
||||
_emptyLabelTemplate() : TemplateResult
|
||||
{
|
||||
if(!this.emptyLabel || this.multiple)
|
||||
{
|
||||
return html``;
|
||||
}
|
||||
return html`
|
||||
<sl-option value=""
|
||||
.selected=${this.getValueAsArray().some(v => v == "")}
|
||||
>
|
||||
${this.emptyLabel}
|
||||
</sl-option>`;
|
||||
}
|
||||
|
||||
protected _optionsTemplate() : TemplateResult
|
||||
{
|
||||
return html`${repeat(this.select_options
|
||||
// Filter out empty values if we have empty label to avoid duplicates
|
||||
.filter(o => this.emptyLabel ? o.value !== '' : o), this._groupTemplate.bind(this))
|
||||
}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to render each option into the select
|
||||
*
|
||||
* @param {SelectOption} option
|
||||
* @returns {TemplateResult}
|
||||
*/
|
||||
protected _optionTemplate(option : SelectOption) : TemplateResult
|
||||
{
|
||||
// Exclude non-matches when searching
|
||||
if(typeof option.isMatch == "boolean" && !option.isMatch)
|
||||
{
|
||||
return html``;
|
||||
}
|
||||
|
||||
// Tag used must match this.optionTag, but you can't use the variable directly.
|
||||
// Pass option along so SearchMixin can grab it if needed
|
||||
const value = (<string>option.value).replaceAll(" ", "___");
|
||||
return html`
|
||||
<sl-option
|
||||
part="option"
|
||||
value="${value}"
|
||||
title="${!option.title || this.noLang ? option.title : this.egw().lang(option.title)}"
|
||||
class="${option.class}" .option=${option}
|
||||
.selected=${this.getValueAsArray().some(v => v == value)}
|
||||
?disabled=${option.disabled}
|
||||
>
|
||||
${this._iconTemplate(option)}
|
||||
${this.noLang ? option.label : this.egw().lang(option.label)}
|
||||
</sl-option>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag used for rendering tags when multiple=true
|
||||
* Used for creating, finding & filtering options.
|
||||
@ -548,116 +575,13 @@ export class Et2Select extends Et2WithSearchMixin(Et2WidgetWithSelect)
|
||||
return literal`et2-tag`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom tag
|
||||
* @param {Et2Option} option
|
||||
* @param {number} index
|
||||
* @returns {TemplateResult}
|
||||
* @protected
|
||||
*/
|
||||
protected _tagTemplate(option : Et2Option, index : number) : TemplateResult
|
||||
{
|
||||
const readonly = (this.readonly || option && typeof (option.disabled) != "undefined" && option.disabled);
|
||||
const isEditable = this.editModeEnabled && !readonly;
|
||||
const image = this._createImage(option);
|
||||
const tagName = this.tagTag;
|
||||
return html`
|
||||
<${tagName}
|
||||
part="tag"
|
||||
exportparts="
|
||||
base:tag__base,
|
||||
content:tag__content,
|
||||
remove-button:tag__remove-button,
|
||||
remove-button__base:tag__remove-button__base,
|
||||
icon:icon
|
||||
"
|
||||
class=${"search_tag " + option.classList.value}
|
||||
?pill=${this.pill}
|
||||
size=${this.size}
|
||||
?removable=${!readonly}
|
||||
?readonly=${readonly}
|
||||
?editable=${isEditable}
|
||||
value=${option.value}
|
||||
@dblclick=${this._handleDoubleClick}
|
||||
@click=${typeof this.onTagClick == "function" ? (e) => this.onTagClick(e, e.target) : nothing}
|
||||
>
|
||||
${image ?? nothing}
|
||||
${option.getTextLabel().trim()}
|
||||
</${tagName}>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Additional customisation template
|
||||
* @returns {*}
|
||||
* @protected
|
||||
*/
|
||||
protected _extraTemplate()
|
||||
{
|
||||
return typeof super._extraTemplate == "function" ? super._extraTemplate() : nothing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Customise how tags are rendered. This overrides what SlSelect
|
||||
* does in syncItemsFromValue().
|
||||
* This is a copy+paste from SlSelect.syncItemsFromValue().
|
||||
*
|
||||
* @param item
|
||||
* @protected
|
||||
*/
|
||||
protected _createTagNode(item)
|
||||
{
|
||||
console.warn("Deprecated");
|
||||
debugger;
|
||||
let tag;
|
||||
if(typeof super._createTagNode == "function")
|
||||
{
|
||||
tag = super._createTagNode(item);
|
||||
}
|
||||
else
|
||||
{
|
||||
tag = <Et2Tag>document.createElement(this.tagTag);
|
||||
}
|
||||
tag.value = item.value;
|
||||
tag.textContent = item?.getTextLabel()?.trim();
|
||||
tag.class = item.classList.value + " search_tag";
|
||||
tag.setAttribute("exportparts", "icon");
|
||||
if(this.size)
|
||||
{
|
||||
tag.size = this.size;
|
||||
}
|
||||
if(this.readonly || item.option && typeof (item.option.disabled) != "undefined" && item.option.disabled)
|
||||
{
|
||||
tag.removable = false;
|
||||
tag.readonly = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
tag.addEventListener("dblclick", this._handleDoubleClick);
|
||||
tag.addEventListener("click", this.handleTagInteraction);
|
||||
tag.addEventListener("keydown", this.handleTagInteraction);
|
||||
tag.addEventListener("sl-remove", (event : CustomEvent) => this.handleTagRemove(event, item));
|
||||
}
|
||||
// Allow click handler even if read only
|
||||
if(typeof this.onTagClick == "function")
|
||||
{
|
||||
tag.addEventListener("click", (e) => this.onTagClick(e, e.target));
|
||||
}
|
||||
let image = this._createImage(item);
|
||||
if(image)
|
||||
{
|
||||
tag.prepend(image);
|
||||
}
|
||||
return tag;
|
||||
}
|
||||
|
||||
blur()
|
||||
{
|
||||
if(typeof super.blur == "function")
|
||||
{
|
||||
super.blur();
|
||||
}
|
||||
this.dropdown.hide();
|
||||
this.hide();
|
||||
}
|
||||
|
||||
/* Parent should be fine now?
|
||||
@ -711,7 +635,9 @@ export class Et2Select extends Et2WithSearchMixin(Et2WidgetWithSelect)
|
||||
protected handleValueChange(e : SlChangeEvent)
|
||||
{
|
||||
const old_value = this.__value;
|
||||
this.__value = this.select.value;
|
||||
this.__value = Array.isArray(this.select.value) ?
|
||||
this.select.value.map(e => e.replaceAll("___", " ")) :
|
||||
this.select.value.replaceAll("___", " ");
|
||||
this.requestUpdate("value", old_value);
|
||||
}
|
||||
|
||||
@ -739,37 +665,6 @@ export class Et2Select extends Et2WithSearchMixin(Et2WidgetWithSelect)
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the icon for the select option
|
||||
*
|
||||
* @param option
|
||||
* @protected
|
||||
*/
|
||||
protected _iconTemplate(option)
|
||||
{
|
||||
if(!option.icon)
|
||||
{
|
||||
return html``;
|
||||
}
|
||||
|
||||
return html`
|
||||
<et2-image slot="prefix" part="icon" style="width: var(--icon-width)"
|
||||
src="${option.icon}"></et2-image>`
|
||||
}
|
||||
|
||||
protected _createImage(item)
|
||||
{
|
||||
let image = item?.querySelector ? item.querySelector("et2-image") || item.querySelector("[slot='prefix']") : null;
|
||||
if(image)
|
||||
{
|
||||
image = image.clone();
|
||||
image.slot = "prefix";
|
||||
image.class = "tag_image";
|
||||
return image;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Shows the listbox. */
|
||||
async show()
|
||||
{
|
||||
@ -795,6 +690,162 @@ export class Et2Select extends Et2WithSearchMixin(Et2WidgetWithSelect)
|
||||
return this.shadowRoot?.querySelector("sl-select");
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom, dynamic styling
|
||||
*
|
||||
* Put as much as you can in static styles for performance reasons
|
||||
* Override this for custom dynamic styles
|
||||
*
|
||||
* @returns {TemplateResult}
|
||||
* @protected
|
||||
*/
|
||||
protected _styleTemplate() : TemplateResult
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used for the "no value" option for single select
|
||||
* Placeholder is used for multi-select with no value
|
||||
*
|
||||
* @returns {TemplateResult}
|
||||
*/
|
||||
_emptyLabelTemplate() : TemplateResult
|
||||
{
|
||||
if(!this.emptyLabel || this.multiple)
|
||||
{
|
||||
return html``;
|
||||
}
|
||||
return html`
|
||||
<sl-option
|
||||
part="emptyLabel option"
|
||||
value=""
|
||||
.selected=${this.getValueAsArray().some(v => v == "")}
|
||||
>
|
||||
${this.emptyLabel}
|
||||
</sl-option>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate over all the options
|
||||
* @returns {TemplateResult}
|
||||
* @protected
|
||||
*/
|
||||
protected _optionsTemplate() : TemplateResult
|
||||
{
|
||||
return html`${repeat(this.select_options
|
||||
// Filter out empty values if we have empty label to avoid duplicates
|
||||
.filter(o => this.emptyLabel ? o.value !== '' : o), this._groupTemplate.bind(this))
|
||||
}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to render each option into the select
|
||||
* Override for custom select options. Note that spaces are not allowed in option values,
|
||||
* and sl-select _requires_ options to be <sl-option>
|
||||
*
|
||||
* @param {SelectOption} option
|
||||
* @returns {TemplateResult}
|
||||
*/
|
||||
protected _optionTemplate(option : SelectOption) : TemplateResult
|
||||
{
|
||||
// Exclude non-matches when searching
|
||||
if(typeof option.isMatch == "boolean" && !option.isMatch)
|
||||
{
|
||||
return html``;
|
||||
}
|
||||
|
||||
// Tag used must match this.optionTag, but you can't use the variable directly.
|
||||
// Pass option along so SearchMixin can grab it if needed
|
||||
const value = (<string>option.value).replaceAll(" ", "___");
|
||||
return html`
|
||||
<sl-option
|
||||
part="option"
|
||||
value="${value}"
|
||||
title="${!option.title || this.noLang ? option.title : this.egw().lang(option.title)}"
|
||||
class="${option.class}" .option=${option}
|
||||
.selected=${this.getValueAsArray().some(v => v == value)}
|
||||
?disabled=${option.disabled}
|
||||
>
|
||||
${this._iconTemplate(option)}
|
||||
${this.noLang ? option.label : this.egw().lang(option.label)}
|
||||
</sl-option>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the icon for the select option
|
||||
*
|
||||
* @param option
|
||||
* @protected
|
||||
*/
|
||||
protected _iconTemplate(option)
|
||||
{
|
||||
if(!option.icon)
|
||||
{
|
||||
return html``;
|
||||
}
|
||||
|
||||
return html`
|
||||
<et2-image slot="prefix" part="icon" style="width: var(--icon-width)"
|
||||
src="${option.icon}"></et2-image>`
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Custom tag
|
||||
*
|
||||
* Override this to customise display when multiple=true.
|
||||
* There is no restriction on the tag used, unlike _optionTemplate()
|
||||
*
|
||||
* @param {Et2Option} option
|
||||
* @param {number} index
|
||||
* @returns {TemplateResult}
|
||||
* @protected
|
||||
*/
|
||||
protected _tagTemplate(option : SlOption, index : number) : TemplateResult
|
||||
{
|
||||
const readonly = (this.readonly || option && typeof (option.disabled) != "undefined" && option.disabled);
|
||||
const isEditable = this.editModeEnabled && !readonly;
|
||||
const image = this._iconTemplate(option);
|
||||
const tagName = this.tagTag;
|
||||
return html`
|
||||
<${tagName}
|
||||
part="tag"
|
||||
exportparts="
|
||||
base:tag__base,
|
||||
content:tag__content,
|
||||
remove-button:tag__remove-button,
|
||||
remove-button__base:tag__remove-button__base,
|
||||
icon:icon
|
||||
"
|
||||
class=${"search_tag " + option.classList.value}
|
||||
?pill=${this.pill}
|
||||
size=${this.size || "medium"}
|
||||
?removable=${!readonly}
|
||||
?readonly=${readonly}
|
||||
?editable=${isEditable}
|
||||
.value=${option.value.replaceAll("___", " ")}
|
||||
@change=${this.handleTagEdit}
|
||||
@dblclick=${this._handleDoubleClick}
|
||||
@click=${typeof this.onTagClick == "function" ? (e) => this.onTagClick(e, e.target) : nothing}
|
||||
>
|
||||
${image ?? nothing}
|
||||
${option.getTextLabel().trim()}
|
||||
</${tagName}>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Additional customisation template
|
||||
* Override if needed. Added after select options.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
protected _extraTemplate() : TemplateResult | typeof nothing
|
||||
{
|
||||
return typeof super._extraTemplate == "function" ? super._extraTemplate() : nothing;
|
||||
}
|
||||
|
||||
public render()
|
||||
{
|
||||
const value = Array.isArray(this.value) ?
|
||||
@ -811,12 +862,13 @@ export class Et2Select extends Et2WithSearchMixin(Et2WidgetWithSelect)
|
||||
}
|
||||
}
|
||||
return html`
|
||||
${this._styleTemplate()}
|
||||
<sl-select
|
||||
exportparts="prefix, tags, display-input, expand-icon, combobox, listbox, option"
|
||||
label=${this.label}
|
||||
placeholder=${this.placeholder}
|
||||
placeholder=${this.placeholder || (this.multiple && this.emptyLabel ? this.emptyLabel : "")}
|
||||
?multiple=${this.multiple}
|
||||
?disabled=${this.disabled}
|
||||
?disabled=${this.disabled || this.readonly}
|
||||
?clearable=${this.clearable}
|
||||
?required=${this.required}
|
||||
helpText=${this.helpText}
|
||||
|
@ -160,7 +160,7 @@ export const Et2WidgetWithSelectMixin = <T extends Constructor<LitElement>>(supe
|
||||
{
|
||||
return this.value;
|
||||
}
|
||||
if(this.value == "null" || typeof this.value == "undefined" || !this.emptyLabel && this.value == "")
|
||||
if(this.value == "null" || this.value == null || typeof this.value == "undefined" || !this.emptyLabel && this.value == "")
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -7,13 +7,13 @@
|
||||
* @author Nathan Gray
|
||||
*/
|
||||
|
||||
import {css, CSSResultGroup, html, LitElement, nothing, render, TemplateResult} from "lit";
|
||||
import {css, CSSResultGroup, html, LitElement, nothing, TemplateResult} from "lit";
|
||||
import {cleanSelectOptions, SelectOption} from "./FindSelectOptions";
|
||||
import {Validator} from "@lion/form-core";
|
||||
import {Et2Tag} from "./Tag/Et2Tag";
|
||||
import {SlMenuItem} from "@shoelace-style/shoelace";
|
||||
import {StaticOptions} from "./StaticOptions";
|
||||
import {dedupeMixin} from "@open-wc/dedupe-mixin";
|
||||
import {SlOption} from "@shoelace-style/shoelace";
|
||||
|
||||
// Otherwise import gets stripped
|
||||
let keep_import : Et2Tag;
|
||||
@ -71,7 +71,7 @@ export declare class SearchMixinInterface
|
||||
*
|
||||
* @type {TemplateResult}
|
||||
*/
|
||||
_extraTemplate : TemplateResult
|
||||
_extraTemplate : TemplateResult | typeof nothing
|
||||
}
|
||||
|
||||
/**
|
||||
@ -409,7 +409,7 @@ export const Et2WithSearchMixin = dedupeMixin(<T extends Constructor<LitElement>
|
||||
}
|
||||
}
|
||||
|
||||
protected _extraTemplate()
|
||||
protected _extraTemplate() : TemplateResult | typeof nothing
|
||||
{
|
||||
if(!this.searchEnabled && !this.editModeEnabled && !this.allowFreeEntries || this.readonly)
|
||||
{
|
||||
@ -419,6 +419,7 @@ export const Et2WithSearchMixin = dedupeMixin(<T extends Constructor<LitElement>
|
||||
return html`
|
||||
${this._searchInputTemplate()}
|
||||
${this._moreResultsTemplate()}
|
||||
${this._noResultsTemplate()}
|
||||
`;
|
||||
}
|
||||
|
||||
@ -463,6 +464,11 @@ export const Et2WithSearchMixin = dedupeMixin(<T extends Constructor<LitElement>
|
||||
|
||||
protected _noResultsTemplate()
|
||||
{
|
||||
if(this._total_result_count !== 0 || !this._searchInputNode?.value)
|
||||
{
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="no-results">${this.egw().lang("no suggestions")}</div>`;
|
||||
}
|
||||
@ -543,9 +549,9 @@ export const Et2WithSearchMixin = dedupeMixin(<T extends Constructor<LitElement>
|
||||
|
||||
if(this.allowFreeEntries)
|
||||
{
|
||||
this.freeEntries.forEach((item : SlMenuItem) =>
|
||||
this.freeEntries.forEach((item : SlOption) =>
|
||||
{
|
||||
if(!options.some(i => i.value == item.value))
|
||||
if(!options.some(i => i.value == item.value.replaceAll("___", " ")))
|
||||
{
|
||||
options.push({value: item.value, label: item.textContent, class: item.classList.toString()});
|
||||
}
|
||||
@ -681,6 +687,8 @@ export const Et2WithSearchMixin = dedupeMixin(<T extends Constructor<LitElement>
|
||||
{
|
||||
return;
|
||||
}
|
||||
this.setAttribute("open", "");
|
||||
|
||||
// Move search (& menu) if there's no value
|
||||
this._activeControls?.classList.toggle("novalue", this.multiple && this.value == '' || !this.multiple);
|
||||
|
||||
@ -743,8 +751,10 @@ export const Et2WithSearchMixin = dedupeMixin(<T extends Constructor<LitElement>
|
||||
|
||||
focus()
|
||||
{
|
||||
this.show();
|
||||
this._searchInputNode.focus();
|
||||
this.show().then(() =>
|
||||
{
|
||||
this._searchInputNode?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
_handleMenuHide()
|
||||
@ -753,6 +763,8 @@ export const Et2WithSearchMixin = dedupeMixin(<T extends Constructor<LitElement>
|
||||
{
|
||||
return;
|
||||
}
|
||||
this.removeAttribute("open");
|
||||
|
||||
this.clearSearch();
|
||||
|
||||
// Reset display
|
||||
@ -933,7 +945,7 @@ export const Et2WithSearchMixin = dedupeMixin(<T extends Constructor<LitElement>
|
||||
else if(event.key == "Escape")
|
||||
{
|
||||
this._handleSearchAbort(event);
|
||||
this.dropdown.hide();
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1017,7 +1029,7 @@ export const Et2WithSearchMixin = dedupeMixin(<T extends Constructor<LitElement>
|
||||
// Show a spinner
|
||||
let spinner = document.createElement("sl-spinner");
|
||||
spinner.slot = "expand-icon";
|
||||
this.appendChild(spinner);
|
||||
this.select.appendChild(spinner);
|
||||
|
||||
// Hide clear button
|
||||
let clear_button = <HTMLElement>this._searchInputNode.shadowRoot.querySelector(".input__clear")
|
||||
@ -1037,15 +1049,6 @@ export const Et2WithSearchMixin = dedupeMixin(<T extends Constructor<LitElement>
|
||||
this.remoteSearch(this._searchInputNode.value, this.searchOptions)
|
||||
]).then(() =>
|
||||
{
|
||||
// Show no results indicator
|
||||
if(this.getAllOptions().filter(e => !e.classList.contains("no-match")).length == 0)
|
||||
{
|
||||
let target = this._optionTargetNode || this;
|
||||
let temp = document.createElement("div");
|
||||
render(this._noResultsTemplate(), temp);
|
||||
target.append(temp.children[0]);
|
||||
}
|
||||
|
||||
// Remove spinner
|
||||
spinner.remove();
|
||||
|
||||
@ -1080,9 +1083,6 @@ export const Et2WithSearchMixin = dedupeMixin(<T extends Constructor<LitElement>
|
||||
{
|
||||
let target = this._optionTargetNode || this;
|
||||
|
||||
// Remove "no suggestions"
|
||||
target.querySelector(".no-results")?.remove();
|
||||
|
||||
// Remove any previously selected remote options that aren't used anymore
|
||||
this._selected_remote = this._selected_remote.filter((option) =>
|
||||
{
|
||||
@ -1093,14 +1093,7 @@ export const Et2WithSearchMixin = dedupeMixin(<T extends Constructor<LitElement>
|
||||
{
|
||||
return prev + ":not([value='" + ('' + current.value).replace(/'/g, "\\\'") + "'])";
|
||||
}, "");
|
||||
target.querySelectorAll(".remote" + keepers).forEach(o => o.remove());
|
||||
target.childNodes.forEach((n) =>
|
||||
{
|
||||
if(n.nodeType == Node.COMMENT_NODE)
|
||||
{
|
||||
n.remove();
|
||||
}
|
||||
})
|
||||
|
||||
// Not searching anymore, clear flag
|
||||
this.select_options.map((o) => o.isMatch = null);
|
||||
this.requestUpdate("select_options");
|
||||
@ -1301,7 +1294,7 @@ export const Et2WithSearchMixin = dedupeMixin(<T extends Constructor<LitElement>
|
||||
return false;
|
||||
}
|
||||
// Make sure not to double-add
|
||||
if(!this.querySelector("[value='" + text.replace(/'/g, "\\\'") + "']") && !this.__select_options.find(o => o.value == text))
|
||||
if(!this.querySelector("[value='" + text.replace(/'/g, "\\\'") + "']") && !this.select_options.find(o => o.value == text))
|
||||
{
|
||||
this.__select_options.push(<SelectOption>{
|
||||
value: text.trim(),
|
||||
@ -1312,20 +1305,17 @@ export const Et2WithSearchMixin = dedupeMixin(<T extends Constructor<LitElement>
|
||||
}
|
||||
|
||||
// Make sure not to double-add, but wait until the option is there
|
||||
this.updateComplete.then(() =>
|
||||
if(this.multiple && this.getValueAsArray().indexOf(text) == -1)
|
||||
{
|
||||
if(this.multiple && this.getValueAsArray().indexOf(text) == -1)
|
||||
{
|
||||
let value = this.getValueAsArray();
|
||||
value.push(text);
|
||||
this.value = value;
|
||||
}
|
||||
else if(!this.multiple && this.value !== text)
|
||||
{
|
||||
this.value = text;
|
||||
}
|
||||
this.requestUpdate("value");
|
||||
});
|
||||
let value = this.getValueAsArray();
|
||||
value.push(text);
|
||||
this.value = value;
|
||||
}
|
||||
else if(!this.multiple && this.value !== text)
|
||||
{
|
||||
this.value = text;
|
||||
}
|
||||
this.dispatchEvent(new Event("change", {bubbles: true}));
|
||||
|
||||
// If we were overlapping edit inputbox with the value display, reset
|
||||
if(!this.readonly && this._activeControls?.classList.contains("novalue"))
|
||||
@ -1379,7 +1369,6 @@ export const Et2WithSearchMixin = dedupeMixin(<T extends Constructor<LitElement>
|
||||
{
|
||||
this.value = value;
|
||||
}
|
||||
this.querySelector("[value='" + original.replace(/'/g, "\\\'") + "']")?.remove();
|
||||
this.__select_options = this.__select_options.filter(v => v.value !== original);
|
||||
}
|
||||
}
|
||||
|
@ -70,14 +70,6 @@ export class Et2SelectAccount extends SelectAccountMixin(Et2StaticSelectMixin(Et
|
||||
this.fetchComplete = Promise.all(fetch);
|
||||
}
|
||||
|
||||
|
||||
firstUpdated(changedProperties?)
|
||||
{
|
||||
super.firstUpdated(changedProperties);
|
||||
// Due to the different way Et2SelectAccount handles options, we call this explicitly
|
||||
this._renderOptions();
|
||||
}
|
||||
|
||||
set accountType(type : AccountType)
|
||||
{
|
||||
this.__accountType = type;
|
||||
|
@ -105,15 +105,15 @@ export class Et2SelectCategory extends Et2StaticSelectMixin(Et2Select)
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to render each option into the select
|
||||
* Overridden for colors
|
||||
* Custom, dynamic styling
|
||||
*
|
||||
* CSS variables are not making it through to options, re-declaring them here works
|
||||
*
|
||||
* @param {SelectOption} option
|
||||
* @returns {TemplateResult}
|
||||
* @protected
|
||||
*/
|
||||
public render() : TemplateResult
|
||||
protected _styleTemplate() : TemplateResult
|
||||
{
|
||||
/** CSS variables are not making it through to options, re-declaring them here works */
|
||||
return html`
|
||||
<style>
|
||||
${repeat(this.select_options, (option) =>
|
||||
@ -128,7 +128,6 @@ export class Et2SelectCategory extends Et2StaticSelectMixin(Et2Select)
|
||||
);
|
||||
})}
|
||||
</style>
|
||||
${super.render()}
|
||||
`;
|
||||
}
|
||||
|
||||
@ -141,20 +140,6 @@ export class Et2SelectCategory extends Et2StaticSelectMixin(Et2Select)
|
||||
{
|
||||
return literal`et2-category-tag`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Customise how tags are rendered.
|
||||
* This overrides parent to set application
|
||||
*
|
||||
* @param item
|
||||
* @protected
|
||||
*/
|
||||
protected _createTagNode(item)
|
||||
{
|
||||
let tag = super._createTagNode(item);
|
||||
tag.application = this.application;
|
||||
return tag;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("et2-select-cat", Et2SelectCategory);
|
@ -12,6 +12,7 @@ import {Et2Select} from "../Et2Select";
|
||||
import {Et2StaticSelectMixin, StaticOptions as so} from "../StaticOptions";
|
||||
import {egw} from "../../../jsapi/egw_global";
|
||||
import {SelectOption} from "../FindSelectOptions";
|
||||
import {html} from "lit";
|
||||
|
||||
/**
|
||||
* Customised Select widget for countries
|
||||
@ -46,14 +47,51 @@ export class Et2SelectCountry extends Et2StaticSelectMixin(Et2Select)
|
||||
connectedCallback()
|
||||
{
|
||||
super.connectedCallback();
|
||||
|
||||
// Add element for current value flag
|
||||
this.querySelector("[slot=prefix].tag_image")?.remove();
|
||||
let image = document.createElement("span");
|
||||
image.slot = "prefix";
|
||||
image.classList.add("tag_image", "flag");
|
||||
this.appendChild(image);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the element for the flag
|
||||
*
|
||||
* @param option
|
||||
* @protected
|
||||
*/
|
||||
protected _iconTemplate(option)
|
||||
{
|
||||
return html`
|
||||
<span slot="prefix" part="flag country_${option.value}_flag"
|
||||
style="width: var(--icon-width)">
|
||||
</span>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to render each option into the select
|
||||
* Override to get flags in
|
||||
*
|
||||
* @param {SelectOption} option
|
||||
* @returns {TemplateResult}
|
||||
*
|
||||
protected _optionTemplate(option : SelectOption) : TemplateResult
|
||||
{
|
||||
// Exclude non-matches when searching
|
||||
if(typeof option.isMatch == "boolean" && !option.isMatch)
|
||||
{
|
||||
return html``;
|
||||
}
|
||||
|
||||
return html`
|
||||
<sl-option
|
||||
part="option"
|
||||
value="${value}"
|
||||
title="${!option.title || this.noLang ? option.title : this.egw().lang(option.title)}"
|
||||
class="${option.class}" .option=${option}
|
||||
.selected=${this.getValueAsArray().some(v => v == value)}
|
||||
?disabled=${option.disabled}
|
||||
>
|
||||
${this._iconTemplate(option)}
|
||||
${this.noLang ? option.label : this.egw().lang(option.label)}
|
||||
</sl-option>`;
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
customElements.define("et2-select-country", Et2SelectCountry);
|
@ -148,11 +148,6 @@ export class Et2SelectEmail extends Et2Select
|
||||
}
|
||||
}
|
||||
|
||||
connectedCallback()
|
||||
{
|
||||
super.connectedCallback();
|
||||
}
|
||||
|
||||
protected _bindListeners()
|
||||
{
|
||||
super._bindListeners();
|
||||
@ -207,15 +202,21 @@ export class Et2SelectEmail extends Et2Select
|
||||
*/
|
||||
_tagTemplate(option, index)
|
||||
{
|
||||
const readonly = (this.readonly || option && typeof (option.disabled) != "undefined" && option.disabled);
|
||||
const isEditable = this.editModeEnabled && !readonly;
|
||||
|
||||
return html`
|
||||
<et2-email-tag
|
||||
class=${classMap({
|
||||
...option.classList,
|
||||
"et2-select-draggable": !this.readonly && this.allowFreeEntries && this.allowDragAndDrop
|
||||
})}
|
||||
?.fullEmail=${this.fullEmail}
|
||||
?.onlyEmail=${this.onlyEmail}
|
||||
value=${option.value}
|
||||
.fullEmail=${this.fullEmail}
|
||||
.onlyEmail=${this.onlyEmail}
|
||||
?removable=${!readonly}
|
||||
?readonly=${readonly}
|
||||
?editable=${isEditable}
|
||||
.value=${option.value.replaceAll("___", " ")}
|
||||
>
|
||||
${option.getTextLabel().trim()}
|
||||
</et2-email-tag>
|
||||
@ -242,16 +243,6 @@ export class Et2SelectEmail extends Et2Select
|
||||
</et2-lavatar>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override image to skip it, we add images in Et2EmailTag using CSS
|
||||
* @param item
|
||||
* @protected
|
||||
*/
|
||||
protected _createImage(item)
|
||||
{
|
||||
return this.multiple ? "" : super._createImage(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwritten to NOT split RFC822 addresses containing a comma in quoted name part
|
||||
*
|
||||
|
@ -1,13 +1,16 @@
|
||||
import {Et2Select} from "../Et2Select";
|
||||
import {Et2StaticSelectMixin, StaticOptions} from "../StaticOptions";
|
||||
import {cleanSelectOptions} from "../FindSelectOptions";
|
||||
|
||||
export class Et2SelectTimezone extends Et2StaticSelectMixin(Et2Select)
|
||||
{
|
||||
constructor()
|
||||
{
|
||||
super();
|
||||
|
||||
this._static_options = StaticOptions.timezone(this, {other: this.other || []});
|
||||
this.fetchComplete = StaticOptions.timezone(this, {other: this.other ?? []}).then((options) =>
|
||||
{
|
||||
this.set_static_options(cleanSelectOptions(options));
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,285 +0,0 @@
|
||||
/**
|
||||
* Some static options, no need to transfer them over and over.
|
||||
* We still need the same thing on the server side to validate, so they
|
||||
* have to match. See Etemplate\Widget\Select::typeOptions()
|
||||
* The type specific legacy options wind up in attrs.other, but should be explicitly
|
||||
* defined and set.
|
||||
*
|
||||
* @param {type} widget
|
||||
*/
|
||||
import { sprintf } from "../../egw_action/egw_action_common";
|
||||
import { cleanSelectOptions, find_select_options } from "./FindSelectOptions";
|
||||
/**
|
||||
* Base class for things that have static options
|
||||
*
|
||||
* We keep static options separate and concatenate them in to allow for extra options without
|
||||
* overwriting them when we get static options from the server
|
||||
*/
|
||||
export const Et2StaticSelectMixin = (superclass) => {
|
||||
class Et2StaticSelectOptions extends (superclass) {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
this.static_options = [];
|
||||
this.fetchComplete = Promise.resolve();
|
||||
// Trigger the options to get rendered into the DOM
|
||||
this.requestUpdate("select_options");
|
||||
}
|
||||
get select_options() {
|
||||
// @ts-ignore
|
||||
const options = super.select_options || [];
|
||||
// make sure result is unique
|
||||
return [...new Map([...(this.static_options || []), ...options].map(item => [item.value, item])).values()];
|
||||
}
|
||||
set select_options(new_options) {
|
||||
// @ts-ignore IDE doesn't recognise property
|
||||
super.select_options = new_options;
|
||||
}
|
||||
set_static_options(new_static_options) {
|
||||
this.static_options = new_static_options;
|
||||
this.requestUpdate("select_options");
|
||||
}
|
||||
/**
|
||||
* Override the parent fix_bad_value() to wait for server-side options
|
||||
* to come back before we check to see if the value is not there.
|
||||
*/
|
||||
fix_bad_value() {
|
||||
this.fetchComplete.then(() => {
|
||||
// @ts-ignore Doesn't know it's an Et2Select
|
||||
if (typeof super.fix_bad_value == "function") {
|
||||
// @ts-ignore Doesn't know it's an Et2Select
|
||||
super.fix_bad_value();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return Et2StaticSelectOptions;
|
||||
};
|
||||
/**
|
||||
* Some options change, or are too complicated to have twice, so we get the
|
||||
* options from the server once, then keep them to use if they're needed again.
|
||||
* We use the options string to keep the different possibilities (eg. categories
|
||||
* for different apps) separate.
|
||||
*
|
||||
* @param {et2_selectbox} widget Selectbox we're looking at
|
||||
* @param {string} options_string
|
||||
* @param {Object} attrs Widget attributes (not yet fully set)
|
||||
* @param {boolean} return_promise true: always return a promise
|
||||
* @returns {Object[]|Promise<Object[]>} Array of options, or empty and they'll get filled in later, or Promise
|
||||
*/
|
||||
export const StaticOptions = new class StaticOptionsType {
|
||||
cached_server_side(widget, type, options_string, return_promise) {
|
||||
// normalize options by removing trailing commas
|
||||
options_string = options_string.replace(/,+$/, '');
|
||||
const cache_id = widget.nodeName + '_' + options_string;
|
||||
const cache_owner = widget.egw().getCache('Et2Select');
|
||||
let cache = cache_owner[cache_id];
|
||||
if (typeof cache === 'undefined') {
|
||||
// Fetch with json instead of jsonq because there may be more than
|
||||
// one widget listening for the response by the time it gets back,
|
||||
// and we can't do that when it's queued.
|
||||
const req = widget.egw().json('EGroupware\\Api\\Etemplate\\Widget\\Select::ajax_get_options', [type, options_string, widget.value]).sendRequest();
|
||||
if (typeof cache === 'undefined') {
|
||||
cache_owner[cache_id] = req;
|
||||
}
|
||||
cache = req;
|
||||
}
|
||||
if (typeof cache.then === 'function') {
|
||||
// pending, wait for it
|
||||
const promise = cache.then((response) => {
|
||||
cache = cache_owner[cache_id] = response.response[0].data || undefined;
|
||||
if (return_promise)
|
||||
return cache;
|
||||
// Set select_options in attributes in case we get a response before
|
||||
// the widget is finished loading (otherwise it will re-set to {})
|
||||
//widget.select_options = cache;
|
||||
// Avoid errors if widget is destroyed before the timeout
|
||||
if (widget && typeof widget.id !== 'undefined') {
|
||||
if (typeof widget.set_static_options == "function") {
|
||||
widget.set_static_options(cache);
|
||||
}
|
||||
else if (typeof widget.set_select_options == "function") {
|
||||
widget.set_select_options(find_select_options(widget, {}, cache));
|
||||
}
|
||||
}
|
||||
});
|
||||
return return_promise ? promise : [];
|
||||
}
|
||||
else {
|
||||
// Check that the value is in there
|
||||
// Make sure we are not requesting server for an empty value option or
|
||||
// other widgets but select-timezone as server won't find anything and
|
||||
// it will fall into an infinitive loop, e.g. select-cat widget.
|
||||
if (widget.value && widget.value != "" && widget.value != "0" && type == "select-timezone") {
|
||||
var missing_option = true;
|
||||
for (var i = 0; i < cache.length && missing_option; i++) {
|
||||
if (cache[i].value == widget.value) {
|
||||
missing_option = false;
|
||||
}
|
||||
}
|
||||
// Try again - ask the server with the current value this time
|
||||
if (missing_option) {
|
||||
delete cache_owner[cache_id];
|
||||
return this.cached_server_side(widget, type, options_string);
|
||||
}
|
||||
else {
|
||||
if (widget.value && widget && widget.get_value() !== widget.value) {
|
||||
egw.window.setTimeout(function () {
|
||||
// Avoid errors if widget is destroyed before the timeout
|
||||
if (this.widget && typeof this.widget.id !== 'undefined') {
|
||||
this.widget.set_value(this.widget.options.value);
|
||||
}
|
||||
}.bind({ widget: widget }), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
return return_promise ? Promise.resolve(cache) : cache;
|
||||
}
|
||||
}
|
||||
cached_from_file(widget, file) {
|
||||
const cache_owner = widget.egw().getCache('Et2Select');
|
||||
let cache = cache_owner[file];
|
||||
if (typeof cache === 'undefined') {
|
||||
cache_owner[file] = cache = widget.egw().window.fetch(file)
|
||||
.then((response) => {
|
||||
// Get the options
|
||||
if (!response.ok) {
|
||||
throw response;
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(options => {
|
||||
var _a;
|
||||
// Need to clean the options because file may be key=>value, may have option list, may be mixed
|
||||
cache_owner[file] = (_a = cleanSelectOptions(options)) !== null && _a !== void 0 ? _a : [];
|
||||
return cache_owner[file];
|
||||
});
|
||||
}
|
||||
else if (cache && typeof cache.then === "undefined") {
|
||||
return Promise.resolve(cache);
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
priority(widget) {
|
||||
return [
|
||||
{ value: "1", label: 'low' },
|
||||
{ value: "2", label: 'normal' },
|
||||
{ value: "3", label: 'high' },
|
||||
{ value: "0", label: 'undefined' }
|
||||
];
|
||||
}
|
||||
bool(widget) {
|
||||
return [
|
||||
{ value: "0", label: 'no' },
|
||||
{ value: "1", label: 'yes' }
|
||||
];
|
||||
}
|
||||
month(widget) {
|
||||
return [
|
||||
{ value: "1", label: 'January' },
|
||||
{ value: "2", label: 'February' },
|
||||
{ value: "3", label: 'March' },
|
||||
{ value: "4", label: 'April' },
|
||||
{ value: "5", label: 'May' },
|
||||
{ value: "6", label: 'June' },
|
||||
{ value: "7", label: 'July' },
|
||||
{ value: "8", label: 'August' },
|
||||
{ value: "9", label: 'September' },
|
||||
{ value: "10", label: 'October' },
|
||||
{ value: "11", label: 'November' },
|
||||
{ value: "12", label: 'December' }
|
||||
];
|
||||
}
|
||||
number(widget, attrs = {
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
interval: undefined,
|
||||
format: undefined
|
||||
}) {
|
||||
var _a, _b, _c, _d;
|
||||
var options = [];
|
||||
var min = (_a = attrs.min) !== null && _a !== void 0 ? _a : parseFloat(widget.min);
|
||||
var max = (_b = attrs.max) !== null && _b !== void 0 ? _b : parseFloat(widget.max);
|
||||
var interval = (_c = attrs.interval) !== null && _c !== void 0 ? _c : parseFloat(widget.interval);
|
||||
var format = (_d = attrs.format) !== null && _d !== void 0 ? _d : '%d';
|
||||
// leading zero specified in interval
|
||||
if (widget.leading_zero && widget.leading_zero[0] == '0') {
|
||||
format = '%0' + ('' + interval).length + 'd';
|
||||
}
|
||||
// Suffix
|
||||
if (widget.suffix) {
|
||||
format += widget.egw().lang(widget.suffix);
|
||||
}
|
||||
// Avoid infinite loop if interval is the wrong direction
|
||||
if ((min <= max) != (interval > 0)) {
|
||||
interval = -interval;
|
||||
}
|
||||
for (var i = 0, n = min; n <= max && i <= 100; n += interval, ++i) {
|
||||
options.push({ value: "" + n, label: sprintf(format, n) });
|
||||
}
|
||||
return options;
|
||||
}
|
||||
percent(widget) {
|
||||
return this.number(widget);
|
||||
}
|
||||
year(widget, attrs) {
|
||||
if (typeof attrs != 'object') {
|
||||
attrs = {};
|
||||
}
|
||||
var t = new Date();
|
||||
attrs.min = t.getFullYear() + parseInt(widget.min);
|
||||
attrs.max = t.getFullYear() + parseInt(widget.max);
|
||||
return this.number(widget, attrs);
|
||||
}
|
||||
day(widget, attrs) {
|
||||
attrs.other = [1, 31, 1];
|
||||
return this.number(widget, attrs);
|
||||
}
|
||||
hour(widget, attrs) {
|
||||
var options = [];
|
||||
var timeformat = widget.egw().preference('common', 'timeformat');
|
||||
for (var h = 0; h <= 23; ++h) {
|
||||
options.push({
|
||||
value: h,
|
||||
label: timeformat == 12 ?
|
||||
((12 ? h % 12 : 12) + ' ' + (h < 12 ? egw.lang('am') : egw.lang('pm'))) :
|
||||
sprintf('%02d', h)
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}
|
||||
app(widget, attrs) {
|
||||
var options = ',' + (attrs.other || []).join(',');
|
||||
return this.cached_server_side(widget, 'select-app', options);
|
||||
}
|
||||
cat(widget) {
|
||||
var options = [widget.globalCategories, /*?*/ , widget.application, widget.parentCat];
|
||||
if (typeof options[3] == 'undefined') {
|
||||
options[3] = widget.application ||
|
||||
// When the widget is first created, it doesn't have a parent and can't find it's instanceManager
|
||||
(widget.getInstanceManager() && widget.getInstanceManager().app) ||
|
||||
widget.egw().app_name();
|
||||
}
|
||||
return this.cached_server_side(widget, 'select-cat', options.join(','), true);
|
||||
}
|
||||
country(widget, attrs, return_promise) {
|
||||
var options = ',';
|
||||
return this.cached_server_side(widget, 'select-country', options, return_promise);
|
||||
}
|
||||
state(widget, attrs) {
|
||||
var options = attrs.country_code ? attrs.country_code : 'de';
|
||||
return this.cached_server_side(widget, 'select-state', options);
|
||||
}
|
||||
dow(widget, attrs) {
|
||||
var options = (widget.rows || "") + ',' + (attrs.other || []).join(',');
|
||||
return this.cached_server_side(widget, 'select-dow', options, true);
|
||||
}
|
||||
lang(widget, attrs) {
|
||||
var options = ',' + (attrs.other || []).join(',');
|
||||
return this.cached_server_side(widget, 'select-lang', options);
|
||||
}
|
||||
timezone(widget, attrs) {
|
||||
var options = ',' + (attrs.other || []).join(',');
|
||||
return this.cached_server_side(widget, 'select-timezone', options);
|
||||
}
|
||||
};
|
||||
//# sourceMappingURL=StaticOptions.js.map
|
@ -67,7 +67,7 @@ export const Et2StaticSelectMixin = <T extends Constructor<Et2WidgetWithSelect>>
|
||||
return options;
|
||||
}
|
||||
// Merge & make sure result is unique
|
||||
return [...new Map([...(this._static_options || []), ...options].map(item =>
|
||||
return [...new Map([...options, ...(this._static_options || [])].map(item =>
|
||||
[item.value, item])).values()];
|
||||
|
||||
}
|
||||
@ -393,9 +393,9 @@ export const StaticOptions = new class StaticOptionsType
|
||||
return this.cached_server_side(widget, 'select-lang', options);
|
||||
}
|
||||
|
||||
timezone(widget : Et2SelectWidgets, attrs) : SelectOption[] | Promise<SelectOption[]>
|
||||
timezone(widget : Et2SelectWidgets, attrs) : Promise<SelectOption[]>
|
||||
{
|
||||
var options = ',' + (attrs.other || []).join(',');
|
||||
return this.cached_server_side(widget, 'select-timezone', options);
|
||||
return <Promise<SelectOption[]>>this.cached_server_side(widget, 'select-timezone', options, true);
|
||||
}
|
||||
}
|
@ -218,15 +218,15 @@ export class Et2EmailTag extends Et2Tag
|
||||
{
|
||||
let content = this.value;
|
||||
// If there's a name, just show the name, otherwise show the email
|
||||
if(!this.onlyEmail && Et2EmailTag.email_cache[this.value])
|
||||
if(!this.onlyEmail && Et2EmailTag.email_cache[content])
|
||||
{
|
||||
// Append current value as email, data may have work & home email in it
|
||||
content = (Et2EmailTag.email_cache[this.value]?.n_fn || "") + " <" + (Et2EmailTag.splitEmail(this.value)?.email || this.value) + ">"
|
||||
content = (Et2EmailTag.email_cache[content]?.n_fn || "") + " <" + (Et2EmailTag.splitEmail(content)?.email || content) + ">"
|
||||
}
|
||||
if (this.onlyEmail)
|
||||
{
|
||||
const split = Et2EmailTag.splitEmail(content);
|
||||
content = split.email || this.value;
|
||||
content = split.email || content;
|
||||
}
|
||||
else if(!this.fullEmail)
|
||||
{
|
||||
|
@ -35,6 +35,9 @@ export class Et2Tag extends Et2Widget(SlTag)
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.tag__prefix {
|
||||
line-height: normal;
|
||||
}
|
||||
.tag__content {
|
||||
padding: 0px 0.2rem;
|
||||
flex: 1 2 auto;
|
||||
|
@ -13,6 +13,7 @@ window.egw = {
|
||||
};
|
||||
|
||||
let element : Et2Select;
|
||||
const tag_name = "et2-tag";
|
||||
|
||||
async function before(editable = true)
|
||||
{
|
||||
@ -20,16 +21,19 @@ async function before(editable = true)
|
||||
// @ts-ignore
|
||||
element = await fixture<Et2Select>(html`
|
||||
<et2-select label="I'm a select" value="one" multiple="true" .editModeEnabled=${editable}>
|
||||
<sl-option value="one">One</sl-option>
|
||||
<sl-option value="two">Two</sl-option>
|
||||
<option value="one">One</option>
|
||||
<option value="two">Two</option>
|
||||
</et2-select>
|
||||
`);
|
||||
// Need to call loadFromXML() explicitly to read the options
|
||||
element.loadFromXML(element);
|
||||
|
||||
// Stub egw()
|
||||
sinon.stub(element, "egw").returns(window.egw);
|
||||
|
||||
await element.updateComplete;
|
||||
let tags = [];
|
||||
element.shadowRoot.querySelectorAll(element.tagTag).forEach((t : Et2Tag) => tags.push(t.updateComplete));
|
||||
element.shadowRoot.querySelectorAll(tag_name).forEach((t : Et2Tag) => tags.push(t.updateComplete));
|
||||
await Promise.all(tags);
|
||||
|
||||
return element;
|
||||
@ -48,30 +52,29 @@ describe("Editable tag", () =>
|
||||
|
||||
it("Tag editable matches editModeEnabled", async() =>
|
||||
{
|
||||
let tag = element.shadowRoot.querySelectorAll(element.tagTag);
|
||||
let tag = element.select.combobox.querySelectorAll(tag_name);
|
||||
assert.isAbove(tag.length, 0, "No tags found");
|
||||
assert.isTrue(tag[0].editable);
|
||||
|
||||
// Change it to false & force immediate update
|
||||
element.editModeEnabled = false;
|
||||
element.syncItemsFromValue();
|
||||
element.requestUpdate();
|
||||
await element.updateComplete;
|
||||
|
||||
tag = element.shadowRoot.querySelectorAll(element.tagTag);
|
||||
tag = element.select.combobox.querySelectorAll(tag_name);
|
||||
assert.isAbove(tag.length, 0, "No tags found");
|
||||
assert.isFalse(tag[0].editable);
|
||||
});
|
||||
|
||||
it("Has edit button when editable ", async() =>
|
||||
{
|
||||
let tag = element.shadowRoot.querySelectorAll(element.tagTag);
|
||||
let tag = element.select.combobox.querySelectorAll(tag_name);
|
||||
assert.isAbove(tag.length, 0, "No tags found");
|
||||
assert.exists(tag[0].shadowRoot.querySelector("et2-button-icon[label='edit*']"), "No edit button");
|
||||
});
|
||||
it("Shows input when edit button is clicked", async() =>
|
||||
{
|
||||
let tag = element.shadowRoot.querySelectorAll(element.tagTag)[0];
|
||||
let tag = element.select.combobox.querySelectorAll(tag_name)[0];
|
||||
|
||||
let edit_button = tag.shadowRoot.querySelector("et2-button-icon");
|
||||
edit_button.click();
|
||||
@ -81,7 +84,7 @@ describe("Editable tag", () =>
|
||||
});
|
||||
it("Changes value when edited", async() =>
|
||||
{
|
||||
let tag = <Et2Tag>element.shadowRoot.querySelectorAll(element.tagTag)[0];
|
||||
let tag = <Et2Tag>element.select.combobox.querySelectorAll(tag_name)[0];
|
||||
tag.isEditing = true;
|
||||
tag.requestUpdate();
|
||||
await tag.updateComplete;
|
||||
@ -119,7 +122,7 @@ describe("Editable tag", () =>
|
||||
await listener2;
|
||||
assert.equal(tag.value, "change select too");
|
||||
|
||||
// Haven't turned on allow free entries, so no change here
|
||||
// Have turned on allow free entries, so it should change here
|
||||
assert.equal(element.value, "change select too", "Tag change did not cause value change in parent select (allowFreeEntries was on)");
|
||||
|
||||
});
|
||||
@ -129,7 +132,7 @@ describe("Editable tag", () =>
|
||||
element.readonly = true;
|
||||
await element.updateComplete;
|
||||
|
||||
let tag = element.shadowRoot.querySelectorAll(element.tagTag);
|
||||
let tag = element.select.combobox.querySelectorAll(tag_name);
|
||||
assert.isAbove(tag.length, 0, "No tags found");
|
||||
|
||||
let wait = [];
|
||||
@ -146,7 +149,7 @@ describe("Select is not editable", () =>
|
||||
|
||||
it("Does not have edit button when not editable", async() =>
|
||||
{
|
||||
let tag = element.shadowRoot.querySelectorAll(element.tagTag);
|
||||
let tag = element.select.combobox.querySelectorAll(tag_name);
|
||||
assert.isAbove(tag.length, 0, "No tags found");
|
||||
|
||||
assert.isNull(tag[0].shadowRoot.querySelector("et2-button-icon[label='edit*']"), "Unexpected edit button");
|
||||
|
@ -9,6 +9,18 @@
|
||||
|
||||
import {assert, fixture, html} from '@open-wc/testing';
|
||||
import {Et2EmailTag} from "../Tag/Et2EmailTag";
|
||||
import * as sinon from 'sinon';
|
||||
|
||||
// Stub global egw
|
||||
// @ts-ignore
|
||||
window.egw = {
|
||||
tooltipUnbind: () => {},
|
||||
lang: i => i + "*",
|
||||
image: () => "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBBZG9iZSBJbGx1c3RyYXRvciAxNS4wLjAsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9uOiA2LjAwIEJ1aWxkIDApICAtLT4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+DQo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkViZW5lXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4Ig0KCSB3aWR0aD0iMzJweCIgaGVpZ2h0PSIzMnB4IiB2aWV3Qm94PSIwIDAgMzIgMzIiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDMyIDMyIiB4bWw6c3BhY2U9InByZXNlcnZlIj4NCjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBmaWxsPSIjNjk2OTY5IiBkPSJNNi45NDMsMjguNDUzDQoJYzAuOTA2LDAuNzY1LDIuMDk3LDEuMTI3LDMuMjg2LDEuMTA5YzAuNDMsMC4wMTQsMC44NTItMC4wNjgsMS4yNjUtMC4yMDdjMC42NzktMC4xOCwxLjMyOC0wLjQ1LDEuODY2LTAuOTAyTDI5LjQwMywxNC45DQoJYzEuNzcyLTEuNDk4LDEuNzcyLTMuOTI1LDAtNS40MjJjLTEuNzcyLTEuNDk3LTQuNjQ2LTEuNDk3LTYuNDE4LDBMMTAuMTE5LDIwLjM0OWwtMi4zODktMi40MjRjLTEuNDQtMS40NTctMy43NzItMS40NTctNS4yMTIsMA0KCWMtMS40MzgsMS40Ni0xLjQzOCwzLjgyNSwwLDUuMjgxQzIuNTE4LDIzLjIwNiw1LjQ3NCwyNi45NDcsNi45NDMsMjguNDUzeiIvPg0KPC9zdmc+DQo=",
|
||||
webserverUrl: "",
|
||||
app: (_app) => _app,
|
||||
jsonq: () => Promise.resolve({})
|
||||
};
|
||||
|
||||
describe('Et2EmailTag', () =>
|
||||
{
|
||||
@ -18,7 +30,13 @@ describe('Et2EmailTag', () =>
|
||||
{
|
||||
component = await fixture<Et2EmailTag>(html`
|
||||
<et2-email-tag value="test@example.com"></et2-email-tag>`);
|
||||
// Stub egw()
|
||||
// @ts-ignore
|
||||
sinon.stub(component, "egw").returns(window.egw);
|
||||
await component.updateComplete;
|
||||
|
||||
// Asserting this instanceOf forces class loading
|
||||
assert.instanceOf(component, Et2EmailTag);
|
||||
});
|
||||
|
||||
it('should be defined', () =>
|
||||
@ -48,7 +66,8 @@ describe('Et2EmailTag', () =>
|
||||
|
||||
it('should open addressbook with email preset on (+) click', () =>
|
||||
{
|
||||
component.egw = () => ({
|
||||
window.egw.open = () =>
|
||||
{
|
||||
open: (url, app, mode, extra) =>
|
||||
{
|
||||
assert.equal(url, '');
|
||||
@ -56,7 +75,7 @@ describe('Et2EmailTag', () =>
|
||||
assert.equal(mode, 'add');
|
||||
assert.equal(extra['presets[email]'], 'test@example.com');
|
||||
}
|
||||
});
|
||||
};
|
||||
component.handleMouseDown(new MouseEvent('click'));
|
||||
});
|
||||
|
||||
@ -70,7 +89,8 @@ describe('Et2EmailTag', () =>
|
||||
};
|
||||
component.value = 'test@example.com';
|
||||
component.checkContact = async(email) => contact;
|
||||
component.egw = () => ({
|
||||
component.egw.open = () =>
|
||||
{
|
||||
open: (id, app, mode, extra) =>
|
||||
{
|
||||
assert.equal(id, contact.id);
|
||||
@ -78,7 +98,7 @@ describe('Et2EmailTag', () =>
|
||||
assert.equal(mode, 'view');
|
||||
assert.deepEqual(extra, {title: contact.n_fn, icon: contact.photo});
|
||||
}
|
||||
});
|
||||
};
|
||||
await component.handleContactMouseDown(new MouseEvent('click'));
|
||||
});
|
||||
});
|
||||
|
@ -25,11 +25,13 @@ async function before()
|
||||
// Create an element to test with, and wait until it's ready
|
||||
// @ts-ignore
|
||||
element = await fixture<Et2Select>(html`
|
||||
<et2-select label="I'm a select"/>
|
||||
<et2-select label="I'm a select">
|
||||
</et2-select>
|
||||
`);
|
||||
|
||||
// Stub egw()
|
||||
sinon.stub(element, "egw").returns(window.egw);
|
||||
await elementUpdated(element);
|
||||
|
||||
return element;
|
||||
}
|
||||
@ -48,7 +50,6 @@ describe("Select widget basics", () =>
|
||||
it('has a label', async() =>
|
||||
{
|
||||
element.set_label("Label set");
|
||||
// @ts-ignore TypeScript doesn't recognize widgets as Elements
|
||||
await elementUpdated(element);
|
||||
|
||||
assert.equal(element.querySelector("[slot='label']").textContent, "Label set");
|
||||
@ -64,28 +65,30 @@ describe("Select widget basics", () =>
|
||||
{
|
||||
// WIP
|
||||
const blurSpy = sinon.spy();
|
||||
element.addEventListener('blur', blurSpy);
|
||||
element.addEventListener('sl-hide', blurSpy);
|
||||
const showPromise = new Promise(resolve =>
|
||||
{
|
||||
element.addEventListener("sl-after-show", resolve);
|
||||
});
|
||||
const hidePromise = new Promise(resolve =>
|
||||
{
|
||||
element.addEventListener("blur", resolve);
|
||||
element.addEventListener("sl-hide", resolve);
|
||||
});
|
||||
|
||||
await elementUpdated(element);
|
||||
element.focus();
|
||||
|
||||
await showPromise;
|
||||
await elementUpdated(element);
|
||||
|
||||
element.blur();
|
||||
await elementUpdated(element);
|
||||
|
||||
await hidePromise;
|
||||
|
||||
sinon.assert.calledOnce(blurSpy);
|
||||
|
||||
// Check that it actually closed dropdown
|
||||
assert.isFalse(element.dropdown?.hasAttribute("open"));
|
||||
assert.isFalse(element.select?.hasAttribute("open"));
|
||||
})
|
||||
});
|
||||
|
||||
@ -97,10 +100,11 @@ describe("Multiple", () =>
|
||||
// @ts-ignore
|
||||
element = await fixture<Et2Select>(html`
|
||||
<et2-select label="I'm a select" multiple="true">
|
||||
<sl-option value="one">One</sl-option>
|
||||
<sl-option value="two">Two</sl-option>
|
||||
<option value="one">One</option>
|
||||
<option value="two">Two</option>
|
||||
</et2-select>
|
||||
`);
|
||||
element.loadFromXML(element);
|
||||
element.set_value("one,two");
|
||||
|
||||
// Stub egw()
|
||||
@ -111,14 +115,14 @@ describe("Multiple", () =>
|
||||
|
||||
it("Can remove tags", async() =>
|
||||
{
|
||||
assert.equal(element.querySelectorAll("sl-option").length, 2, "Did not find options");
|
||||
assert.equal(element.select.querySelectorAll("sl-option").length, 2, "Did not find options");
|
||||
|
||||
assert.sameMembers(element.value, ["one", "two"]);
|
||||
let tags = element.shadowRoot.querySelectorAll('.select__tags > *');
|
||||
let tags = element.select.combobox.querySelectorAll('.select__tags et2-tag');
|
||||
|
||||
// Await tags to render
|
||||
let tag_updates = []
|
||||
element.shadowRoot.querySelectorAll(element.tagTag).forEach((t : Et2Tag) => tag_updates.push(t.updateComplete));
|
||||
element.select.combobox.querySelectorAll("et2-tag").forEach((t : Et2Tag) => tag_updates.push(t.updateComplete));
|
||||
await Promise.all(tag_updates);
|
||||
|
||||
assert.equal(tags.length, 2);
|
||||
@ -138,15 +142,21 @@ describe("Multiple", () =>
|
||||
// Wait for widget to update
|
||||
await element.updateComplete;
|
||||
tag_updates = []
|
||||
element.shadowRoot.querySelectorAll(element.tagTag).forEach((t : Et2Tag) => tag_updates.push(t.updateComplete));
|
||||
element.select.combobox.querySelectorAll('et2-tag').forEach((t : Et2Tag) => tag_updates.push(t.updateComplete));
|
||||
await Promise.all(tag_updates);
|
||||
|
||||
// Check
|
||||
assert.sameMembers(element.value, ["two"], "Removing tag did not remove value");
|
||||
tags = element.shadowRoot.querySelectorAll('.select__tags > *');
|
||||
tags = element.select.combobox.querySelectorAll('.select__tags et2-tag');
|
||||
assert.equal(tags.length, 1, "Removed tag is still there");
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
inputBasicTests(before, "", "select");
|
||||
inputBasicTests(async() =>
|
||||
{
|
||||
const element = await before();
|
||||
element.noLang = true;
|
||||
element.select_options = [{value: "", label: ""}];
|
||||
return element
|
||||
}, "", "sl-select");
|
@ -1,8 +1,9 @@
|
||||
import {assert, elementUpdated, fixture, html} from '@open-wc/testing';
|
||||
import {Et2Box} from "../../Layout/Et2Box/Et2Box";
|
||||
import {Et2Select, SelectOption} from "../Et2Select";
|
||||
import {Et2Select} from "../Et2Select";
|
||||
import * as sinon from "sinon";
|
||||
import {et2_arrayMgr} from "../../et2_core_arrayMgr";
|
||||
import {SelectOption} from "../FindSelectOptions";
|
||||
|
||||
let parser = new window.DOMParser();
|
||||
|
||||
@ -30,7 +31,6 @@ describe("Select widget", () =>
|
||||
beforeEach(async() =>
|
||||
{
|
||||
// This stuff because otherwise Et2Select isn't actually loaded when testing
|
||||
// @ts-ignore TypeScript is not recognizing that this widget is a LitElement
|
||||
element = await fixture<Et2Select>(html`
|
||||
<et2-select></et2-select>
|
||||
`);
|
||||
@ -39,7 +39,6 @@ describe("Select widget", () =>
|
||||
assert.instanceOf(element, Et2Select);
|
||||
element.remove();
|
||||
|
||||
// @ts-ignore TypeScript is not recognizing that this widget is a LitElement
|
||||
container = await fixture<Et2Box>(html`
|
||||
<et2-box/>
|
||||
`);
|
||||
@ -66,7 +65,7 @@ describe("Select widget", () =>
|
||||
await element.updateComplete;
|
||||
|
||||
/** TESTING **/
|
||||
assert.isNotNull(element.querySelector("[value='option']"), "Missing static option");
|
||||
assert.isNotNull(element.select.querySelector("[value='option']"), "Missing static option");
|
||||
});
|
||||
|
||||
it("directly in sel_options", async() =>
|
||||
@ -86,7 +85,7 @@ describe("Select widget", () =>
|
||||
await element.updateComplete;
|
||||
|
||||
/** TESTING **/
|
||||
assert.equal(element.querySelectorAll("sl-option").length, 2);
|
||||
assert.equal(element.select.querySelectorAll("sl-option").length, 2);
|
||||
});
|
||||
|
||||
it("merges static options with sel_options", async() =>
|
||||
@ -107,9 +106,7 @@ describe("Select widget", () =>
|
||||
await element.updateComplete;
|
||||
|
||||
/** TESTING **/
|
||||
|
||||
// @ts-ignore o.value isn't known by TypeScript, but it's there
|
||||
let option_keys = Object.values(element.querySelectorAll("sl-option")).map(o => o.value);
|
||||
let option_keys = Object.values(element.select.querySelectorAll("sl-option")).map(o => o.value);
|
||||
assert.include(option_keys, "option", "Static option missing");
|
||||
assert.includeMembers(option_keys, ["1", "2", "option"], "Option mis-match");
|
||||
assert.equal(option_keys.length, 3);
|
||||
|
@ -9,7 +9,7 @@ import {Et2Box} from "../../Layout/Et2Box/Et2Box";
|
||||
import {Et2Select} from "../Et2Select";
|
||||
import {Et2Textbox} from "../../Et2Textbox/Et2Textbox";
|
||||
|
||||
let keep_import : Et2Textbox = new Et2Textbox();
|
||||
let keep_import : Et2Textbox = null;
|
||||
|
||||
// Stub global egw for cssImage to find
|
||||
// @ts-ignore
|
||||
@ -65,6 +65,7 @@ describe("Search actions", () =>
|
||||
'</et2-select>';
|
||||
|
||||
container.loadFromXML(parser.parseFromString(node, "text/xml"));
|
||||
await elementUpdated(container);
|
||||
|
||||
const change = sinon.spy();
|
||||
let element = <Et2Select>container.getWidgetById('select');
|
||||
@ -72,7 +73,7 @@ describe("Search actions", () =>
|
||||
|
||||
await elementUpdated(element);
|
||||
|
||||
element.value = "two";
|
||||
element.select.querySelector("[value='two']").dispatchEvent(new Event("click"));
|
||||
|
||||
await elementUpdated(element);
|
||||
|
||||
@ -96,7 +97,7 @@ describe("Trigger search", () =>
|
||||
// Create an element to test with, and wait until it's ready
|
||||
// @ts-ignore
|
||||
element = await fixture<Et2Select>(html`
|
||||
<et2-select label="I'm a select" search=true>
|
||||
<et2-select label="I'm a select" search>
|
||||
<sl-option value="one">One</sl-option>
|
||||
<sl-option value="two">Two</sl-option>
|
||||
<sl-option value="three">Three</sl-option>
|
||||
@ -111,6 +112,7 @@ describe("Trigger search", () =>
|
||||
|
||||
await element.updateComplete;
|
||||
await element._searchInputNode.updateComplete;
|
||||
await elementUpdated(element);
|
||||
});
|
||||
|
||||
afterEach(() =>
|
||||
@ -125,11 +127,11 @@ describe("Trigger search", () =>
|
||||
let searchSpy = sinon.spy(element, "startSearch");
|
||||
|
||||
// Send two keypresses, but we need to explicitly set the value
|
||||
element._searchInputNode.dispatchEvent(new KeyboardEvent("keydown", {"key": "o"}));
|
||||
element._searchInputNode.value = "o";
|
||||
element._searchInputNode.dispatchEvent(new KeyboardEvent("keydown", {"key": "o"}));
|
||||
assert(searchSpy.notCalled);
|
||||
element._searchInputNode.dispatchEvent(new KeyboardEvent("keydown", {"key": "n"}));
|
||||
element._searchInputNode.value = "on";
|
||||
element._searchInputNode.dispatchEvent(new KeyboardEvent("keydown", {"key": "n"}));
|
||||
assert(searchSpy.notCalled);
|
||||
|
||||
// Skip the timeout
|
||||
@ -145,8 +147,8 @@ describe("Trigger search", () =>
|
||||
let searchSpy = sinon.spy(element, "startSearch");
|
||||
|
||||
// Send two keypresses, but we need to explicitly set the value
|
||||
element._searchInputNode.dispatchEvent(new KeyboardEvent("keydown", {"key": "o"}));
|
||||
element._searchInputNode.value = "t";
|
||||
element._searchInputNode.dispatchEvent(new KeyboardEvent("keydown", {"key": "o"}));
|
||||
assert(searchSpy.notCalled);
|
||||
element._searchInputNode.dispatchEvent(new KeyboardEvent("keydown", {"key": "Enter"}));
|
||||
|
||||
@ -161,8 +163,8 @@ describe("Trigger search", () =>
|
||||
let searchSpy = sinon.spy(element, "startSearch");
|
||||
|
||||
// Send two keypresses, but we need to explicitly set the value
|
||||
element._searchInputNode.dispatchEvent(new KeyboardEvent("keydown", {"key": "t"}));
|
||||
element._searchInputNode.value = "t";
|
||||
element._searchInputNode.dispatchEvent(new KeyboardEvent("keydown", {"key": "t"}));
|
||||
element._searchInputNode.dispatchEvent(new KeyboardEvent("keydown", {"key": "Escape"}));
|
||||
|
||||
assert(searchSpy.notCalled, "startSearch() was called");
|
||||
|
@ -29,14 +29,16 @@ export class Et2Textarea extends Et2InputWidget(SlTextarea)
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.textarea--resize-vertical .textarea__control {
|
||||
|
||||
.textarea--resize-vertical {
|
||||
height: 100%;
|
||||
}
|
||||
:host::part(form-control) {
|
||||
height: 100%;
|
||||
align-items: stretch !important;
|
||||
}
|
||||
:host::part(base) {
|
||||
|
||||
:host::part(form-control-input), :host::part(textarea) {
|
||||
height: 100%;
|
||||
}
|
||||
`,
|
||||
|
@ -103,6 +103,41 @@ const Et2WidgetMixin = <T extends Constructor>(superClass : T) =>
|
||||
:host([align="right"]) .input-group__input {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Put widget label to the left of the widget */
|
||||
|
||||
::part(form-control) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
::part(form-control-label) {
|
||||
flex: 0 0 auto;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
::part(form-control-input) {
|
||||
flex: 1 1 auto;
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
::part(form-control-help-text) {
|
||||
flex-basis: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Use .et2-label-fixed class to give fixed label size */
|
||||
|
||||
:host(.et2-label-fixed)::part(form-control-label) {
|
||||
width: initial;
|
||||
width: var(--label-width, 8em);
|
||||
}
|
||||
|
||||
:host(.et2-label-fixed)::part(form-control-help-text) {
|
||||
left: calc(var(--sl-spacing-medium) + var(--label-width, 8em));
|
||||
}
|
||||
`];
|
||||
}
|
||||
|
||||
@ -126,14 +161,30 @@ const Et2WidgetMixin = <T extends Constructor>(superClass : T) =>
|
||||
class: {type: String, reflect: true},
|
||||
|
||||
/**
|
||||
* Defines whether this widget is visible.
|
||||
* Not to be confused with an input widget's HTML attribute 'disabled'.",
|
||||
* Defines whether this widget is visibly disabled.
|
||||
*
|
||||
* The widget is still visible, but clearly cannot be interacted with. Widgets disabled in the template
|
||||
* will not return a value to the application code, even if re-enabled via javascript before submitting.
|
||||
* To allow a disabled widget to be re-enabled and return a value, disable via javascript in the app's
|
||||
* et2_ready() instead of an attribute in the template file.
|
||||
*/
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
reflect: true
|
||||
},
|
||||
|
||||
/**
|
||||
* The widget is not visible.
|
||||
*
|
||||
* As far as the user is concerned, the widget does not exist. Widgets hidden with an attribute in the
|
||||
* template may not be created in the DOM, and will not return a value. Widgets can be hidden after creation,
|
||||
* and they may return a value if hidden this way.
|
||||
*/
|
||||
hidden: {
|
||||
type: Boolean,
|
||||
reflect: true
|
||||
},
|
||||
|
||||
/**
|
||||
* Accesskey provides a hint for generating a keyboard shortcut for the current element.
|
||||
* The attribute value must consist of a single printable character.
|
||||
@ -315,12 +366,17 @@ const Et2WidgetMixin = <T extends Constructor>(superClass : T) =>
|
||||
/**
|
||||
* Wrapper on this.disabled because legacy had it.
|
||||
*
|
||||
* @deprecated Use widget.disabled for visually disabled, widget.hidden for visually hidden.
|
||||
* Widgets that are hidden from the server via attribute or $readonlys will not be created.
|
||||
* Widgets that are disabled from the server will not return a value to the application code.
|
||||
*
|
||||
* @param {boolean} value
|
||||
*/
|
||||
set_disabled(value : boolean)
|
||||
{
|
||||
let oldValue = this.disabled;
|
||||
this.disabled = value;
|
||||
this.hidden = value;
|
||||
this.requestUpdate("disabled", oldValue);
|
||||
}
|
||||
|
||||
@ -725,7 +781,7 @@ const Et2WidgetMixin = <T extends Constructor>(superClass : T) =>
|
||||
{
|
||||
widget = loadWebComponent(_nodeName, _node, this);
|
||||
|
||||
if(this.addChild)
|
||||
if(this.addChild && widget)
|
||||
{
|
||||
// webcomponent going into old et2_widget
|
||||
this.addChild(widget);
|
||||
@ -1399,6 +1455,13 @@ export function loadWebComponent(_nodeName : string, _template_node : Element|{[
|
||||
throw Error("Unknown or unregistered WebComponent '" + _nodeName + "', could not find class. Also checked for " + tries.join(','));
|
||||
}
|
||||
}
|
||||
|
||||
// Don't need to create hidden elements
|
||||
if(parent?.hidden || attrs["hidden"] && parent?.getArrayMgr("content") && parent.getArrayMgr("content").parseBoolExpression(attrs["hidden"]))
|
||||
{
|
||||
//return null;
|
||||
}
|
||||
|
||||
const readonly = parent?.getArrayMgr("readonlys") ?
|
||||
(<any>parent.getArrayMgr("readonlys")).isReadOnly(
|
||||
attrs["id"], attrs["readonly"],
|
||||
|
@ -14,6 +14,11 @@ import {classMap} from "lit/directives/class-map.js";
|
||||
import {Et2Widget} from "../../Et2Widget/Et2Widget";
|
||||
import {et2_IDetachedDOM} from "../../et2_core_interfaces";
|
||||
|
||||
/**
|
||||
* @summary A basic wrapper to group other widgets
|
||||
*
|
||||
* @slot - Any other widget
|
||||
*/
|
||||
export class Et2Box extends Et2Widget(LitElement) implements et2_IDetachedDOM
|
||||
{
|
||||
static get styles()
|
||||
|
@ -349,8 +349,13 @@ export class et2_arrayMgr
|
||||
return _ident;
|
||||
}
|
||||
|
||||
parseBoolExpression(_expression : string)
|
||||
parseBoolExpression(_expression : string|number|boolean|undefined)
|
||||
{
|
||||
if (typeof _expression === "boolean")
|
||||
{
|
||||
return _expression;
|
||||
}
|
||||
|
||||
if(typeof _expression === "undefined" || _expression === null)
|
||||
{
|
||||
return false;
|
||||
|
@ -454,6 +454,10 @@ export class etemplate2
|
||||
this.close_prompt = this._close_changed_prompt.bind(this);
|
||||
window.addEventListener("beforeunload", this.close_prompt);
|
||||
}
|
||||
else if (window == egw_topWindow())
|
||||
{
|
||||
window.addEventListener("beforeunload", this.destroy_session);
|
||||
}
|
||||
if(this._etemplate_exec_id)
|
||||
{
|
||||
this.destroy_session = jQuery.proxy(function(ev)
|
||||
|
@ -515,6 +515,8 @@ egw.extend('links', egw.MODULE_GLOBAL, function()
|
||||
const parent = document.getElementById(_parent);
|
||||
const select = document.createElement('et2-select');
|
||||
select.setAttribute('id', 'quick_add_selectbox');
|
||||
// Empty label is required to clear value, but we hide it
|
||||
select.emptyLabel = "Select";
|
||||
select.placement = "bottom end";
|
||||
parent.append(select);
|
||||
const plus = parent.querySelector("span");
|
||||
|
@ -95,7 +95,7 @@ class JsCalendar
|
||||
* @param string $method='PUT' 'PUT', 'POST' or 'PATCH'
|
||||
* @return array
|
||||
*/
|
||||
public static function parseJsEvent(string $json, array $old=[], string $content_type=null, $method='PUT')
|
||||
public static function parseJsEvent(string $json, array $old=[], string $content_type=null, $method='PUT', int $calendar_owner=null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@ -147,7 +147,7 @@ class JsCalendar
|
||||
break;
|
||||
|
||||
case 'participants':
|
||||
$event['participants'] = self::parseParticipants($value);
|
||||
$event['participants'] = self::parseParticipants($value, $strict, $calendar_owner);
|
||||
break;
|
||||
|
||||
case 'priority':
|
||||
@ -537,21 +537,22 @@ class JsCalendar
|
||||
|
||||
const TYPE_PARTICIPANT = 'Participant';
|
||||
|
||||
static $status2jscal = [
|
||||
'U' => 'needs-action',
|
||||
'A' => 'accepted',
|
||||
'R' => 'declined',
|
||||
'T' => 'tentative',
|
||||
//'' => 'delegated',
|
||||
];
|
||||
|
||||
/**
|
||||
* Return participants object
|
||||
*
|
||||
* @param array $event
|
||||
* @return array
|
||||
*/
|
||||
* @todo Resources and Groups without email */
|
||||
protected static function Participants(array $event)
|
||||
{
|
||||
static $status2jscal = [
|
||||
'U' => 'needs-action',
|
||||
'A' => 'accepted',
|
||||
'R' => 'declined',
|
||||
'T' => 'tentative',
|
||||
//'' => 'delegated',
|
||||
];
|
||||
$participants = [];
|
||||
foreach($event['participants'] as $uid => $status)
|
||||
{
|
||||
@ -589,7 +590,7 @@ class JsCalendar
|
||||
'optional' => $role === 'OPT-PARTICIPANT',
|
||||
'informational' => $role === 'NON-PARTICIPANT',
|
||||
]),
|
||||
'participationStatus' => $status2jscal[$status],
|
||||
'participationStatus' => self::$status2jscal[$status],
|
||||
]);
|
||||
$participants[$uid] = $participant;
|
||||
}
|
||||
@ -597,6 +598,101 @@ class JsCalendar
|
||||
return $participants;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse participants object
|
||||
*
|
||||
* @param array $participants
|
||||
* @param bool $strict true: require @types and objects with attributes name, email, ...
|
||||
* @param ?int $calendar_owner owner of the calendar / collection
|
||||
* @return array
|
||||
* @todo Resources and Groups without email
|
||||
*/
|
||||
protected static function parseParticipants(array $participants, bool $strict=true, int $calendar_owner=null)
|
||||
{
|
||||
$parsed = [];
|
||||
|
||||
foreach($participants as $uid => $participant)
|
||||
{
|
||||
if ($strict && (!is_array($participant) || $participant[self::AT_TYPE] !== self::TYPE_PARTICIPANT))
|
||||
{
|
||||
throw new \InvalidArgumentException("Missing or invalid @type: ".json_encode($participant, self::JSON_OPTIONS_ERROR));
|
||||
}
|
||||
elseif (!is_array($participant))
|
||||
{
|
||||
$participant = [
|
||||
'email' => $participant,
|
||||
];
|
||||
}
|
||||
// check if the uid is valid and matches the data in the object
|
||||
if (($test_uid = self::Participants(['participants' => [
|
||||
$uid => 'U'
|
||||
]])) && ($test_uid['email'] ?? null) === $participant['email'] &&
|
||||
($test_uid['kind'] ?? null) === ($participant['kind'] ?? null) &&
|
||||
($test_uid['name'] ?? null) === ($participant['name'] ?? null))
|
||||
{
|
||||
// use $uid as is
|
||||
}
|
||||
else
|
||||
{
|
||||
if (empty($participant['email']) || !preg_match(Api\Etemplate\Widget\Url::EMAIL_PREG, $participant['email']))
|
||||
{
|
||||
throw new \InvalidArgumentException("Missing or invalid email address: ".json_encode($participant, self::JSON_OPTIONS_ERROR));
|
||||
}
|
||||
static $contacts = null;
|
||||
if (!isset($contacts)) $contacts = new Api\Contacts();
|
||||
if ((list($data) = $contacts->search([
|
||||
'email' => $participant['email'],
|
||||
'email_home' => $participant['email'],
|
||||
], ['id','egw_addressbook.account_id as account_id','n_fn'],
|
||||
'egw_addressbook.account_id IS NOT NULL DESC, n_fn IS NOT NULL DESC',
|
||||
'','',false,'OR')))
|
||||
{
|
||||
// found an addressbook entry
|
||||
$uid = $data['account_id'] ? (int)$data['account_id'] : 'c'.$data['id'];
|
||||
}
|
||||
else
|
||||
{
|
||||
$uid = 'e'.(empty($participant['name']) ? $participant['email'] : $participant['name'].' <'.$participant['email'].'>');
|
||||
}
|
||||
}
|
||||
$default_status = $uid === $GLOBALS['egw_info']['user']['account_id'] ? 'A' : 'U';
|
||||
$default_role = $uid === $calendar_owner ? 'CHAIR' : 'REQ-PARTICIPANT';
|
||||
$parsed[$uid] = \calendar_so::combine_status(array_search($participant['participationStatus'] ?? $default_status, self::$status2jscal) ?: $default_status,
|
||||
1, self::jscalRoles2role($participant['roles'] ?? null, $default_role));
|
||||
}
|
||||
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
protected static function jscalRoles2role(array $roles=null, string $default_role=null)
|
||||
{
|
||||
$role = $default_role ?? 'REQ-PARTICIPANT';
|
||||
foreach($roles ?? [] as $name => $value)
|
||||
{
|
||||
if ($value && $role !== 'CHAIR')
|
||||
{
|
||||
switch($name)
|
||||
{
|
||||
case 'owner': // we ignore the owner, it's set automatic to the owner of the calendar/collection
|
||||
break;
|
||||
case 'attendee':
|
||||
$role = 'REQ-PARTICIPANT';
|
||||
break;
|
||||
case 'optional':
|
||||
$role = 'OPT-PARTICIPANT';
|
||||
break;
|
||||
case 'informational':
|
||||
$role = 'NON-PARTICIPANT';
|
||||
break;
|
||||
case 'chair':
|
||||
$role = 'CHAIR';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $role;
|
||||
}
|
||||
|
||||
const TYPE_LOCATION = 'Location';
|
||||
const TYPE_VIRTALLOCATION = 'VirtualLocation';
|
||||
|
||||
|
@ -471,15 +471,18 @@ class Etemplate extends Etemplate\Widget\Template
|
||||
/**
|
||||
* Notify server that eT session/request is no longer needed, because user closed window
|
||||
*
|
||||
* @param string $_exec_id
|
||||
* @param string|string[] $_exec_id
|
||||
*/
|
||||
static public function ajax_destroy_session($_exec_id)
|
||||
{
|
||||
//error_log(__METHOD__."('$_exec_id')");
|
||||
if (($request = Etemplate\Request::read($_exec_id, false)))
|
||||
foreach((array)$_exec_id as $exec_id)
|
||||
{
|
||||
$request->remove_if_not_modified();
|
||||
unset($request);
|
||||
//error_log(__METHOD__."('$_exec_id')");
|
||||
if (($request = Etemplate\Request::read($exec_id, false)))
|
||||
{
|
||||
$request->remove_if_not_modified();
|
||||
unset($request);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1300,7 +1300,7 @@ class Account implements \ArrayAccess
|
||||
{
|
||||
$old_account_ids[] = $row['account_id'];
|
||||
}
|
||||
if ($data['account_id'] && ($ids_to_remove = array_diff($old_account_ids, (array)$data['account_id'])))
|
||||
if (($ids_to_remove = array_diff($old_account_ids, (array)$data['account_id'])))
|
||||
{
|
||||
self::$db->delete(self::VALID_TABLE, $where+array(
|
||||
'account_id' => $ids_to_remove,
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -78,36 +78,8 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Put widget label to the left of the widget, with fixed width */
|
||||
::part(form-control) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
::part(form-control-label) {
|
||||
flex: 0 0 auto;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
::part(form-control-input) {
|
||||
flex: 1 1 auto;
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
::part(form-control-help-text) {
|
||||
flex-basis: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Use .et2-label-fixed class to give fixed label size */
|
||||
.et2-label-fixed::part(form-control-label) {
|
||||
width: initial;
|
||||
width: var(--label-width, 8em);
|
||||
}
|
||||
.et2-label-fixed::part(form-control-help-text) {
|
||||
left: calc(var(--sl-spacing-medium) + var(--label-width,8em));
|
||||
*[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1459,15 +1431,15 @@ div.et2_vfsPath li img {
|
||||
}
|
||||
|
||||
/* Category indents in select options */
|
||||
sl-menu-item.cat_level1::part(label) {
|
||||
sl-option.cat_level1::part(label) {
|
||||
padding-left: var(--sl-spacing-medium, 1em);
|
||||
}
|
||||
|
||||
sl-menu-item.cat_level2::part(label) {
|
||||
sl-option.cat_level2::part(label) {
|
||||
padding-left: calc(2 * var(--sl-spacing-medium, 1em));
|
||||
}
|
||||
|
||||
sl-menu-item.cat_level3::part(label) {
|
||||
sl-option.cat_level3::part(label) {
|
||||
padding-left: calc(3 * var(--sl-spacing-medium, 1em));
|
||||
}
|
||||
|
||||
|
@ -1227,7 +1227,7 @@ class calendar_groupdav extends Api\CalDAV\Handler
|
||||
$type = null;
|
||||
if (($is_json=Api\CalDAV::isJSON($type)))
|
||||
{
|
||||
$event = Api\CalDAV\JsCalendar::parseJsEvent($options['content'], $oldEvent ?? [], $type, $method);
|
||||
$event = Api\CalDAV\JsCalendar::parseJsEvent($options['content'], $oldEvent ?? [], $type, $method, $user);
|
||||
$cal_id = $this->bo->save($event);
|
||||
}
|
||||
else
|
||||
|
@ -56,19 +56,16 @@ export class CalendarOwner extends Et2StaticSelectMixin(Et2Select)
|
||||
*/
|
||||
_optionTemplate(option : SelectOption) : TemplateResult
|
||||
{
|
||||
|
||||
const checked = this.value == null ?
|
||||
option.value === this.value || this.multiple && this.value.indexOf(option.value) >= 0 :
|
||||
this.value.indexOf(option.value) >= 0;
|
||||
|
||||
// Tag used must match this.optionTag, but you can't use the variable directly.
|
||||
// Pass option along so SearchMixin can grab it if needed
|
||||
const value = (<string>option.value).replaceAll(" ", "___");
|
||||
return html`
|
||||
<sl-option value="${option.value}"
|
||||
title="${!option.title || this.noLang ? option.title : this.egw().lang(option.title)}"
|
||||
class="${option.class}" .option=${option}
|
||||
?disabled=${option.disabled}
|
||||
.selected=${checked}
|
||||
<sl-option
|
||||
part="option"
|
||||
exportparts="prefix:tag__prefix, suffix:tag__suffix"
|
||||
value="${option.value}"
|
||||
title="${!option.title || this.noLang ? option.title : this.egw().lang(option.title)}"
|
||||
class="${option.class}" .option=${option}
|
||||
?disabled=${option.disabled}
|
||||
.selected=${this.getValueAsArray().some(v => v == value)}
|
||||
>
|
||||
${this._iconTemplate(option)}
|
||||
${this.noLang ? option.label : this.egw().lang(option.label)}
|
||||
|
@ -99,7 +99,7 @@
|
||||
box-shadow: none;
|
||||
}
|
||||
/* Hide email in sidebox */
|
||||
#calendar-sidebox_owner .title {
|
||||
#calendar-sidebox_owner::part(tag__suffix) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
@ -48,7 +48,7 @@
|
||||
<rows>
|
||||
<row disabled="@no_add" height="60" class="et2_toolbar">
|
||||
<calendar-owner id="participant" allowFreeEntries="true" span="4"
|
||||
empty_label="Add new participants or resource"
|
||||
placeholder="Add new participants or resource"
|
||||
onchange="app.calendar.participantOnChange"/>
|
||||
<et2-hbox width="100%">
|
||||
<et2-textbox type="integer" id="quantity" min="1" statustext="Number of resources to be booked"></et2-textbox>
|
||||
|
@ -115,7 +115,7 @@
|
||||
box-shadow: none;
|
||||
}
|
||||
/* Hide email in sidebox */
|
||||
#calendar-sidebox_owner .title {
|
||||
#calendar-sidebox_owner sl-option::part(suffix) {
|
||||
display: none;
|
||||
}
|
||||
/* Conflict display */
|
||||
@ -2359,52 +2359,12 @@ e.g. the div with class calendar_calTimeGrid is generated by the timeGridWidget
|
||||
background: #f2f2f2;
|
||||
}
|
||||
.calendar_calDayTodos .calendar_calDayTodosTable table td {
|
||||
display: inline-block;
|
||||
padding: 3px;
|
||||
}
|
||||
.calendar_calDayTodos .calendar_calDayTodosTable table td img[src$="svg"] {
|
||||
background-color: #0c5da5;
|
||||
background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIHZpZXdCb3g9IjAgMCAxIDEiIHByZXNlcnZlQXNwZWN0UmF0aW89Im5vbmUiPjxsaW5lYXJHcmFkaWVudCBpZD0iZzc0MyIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIHgxPSIwJSIgeTE9IjAlIiB4Mj0iMCUiIHkyPSIxMDAlIj48c3RvcCBzdG9wLWNvbG9yPSIjMEM1REE1IiBvZmZzZXQ9IjAiLz48c3RvcCBzdG9wLWNvbG9yPSIjMEM1REE1IiBvZmZzZXQ9IjEiLz48L2xpbmVhckdyYWRpZW50PjxyZWN0IHg9IjAiIHk9IjAiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiIGZpbGw9InVybCgjZzc0MykiLz48L3N2Zz4=);
|
||||
background-image: -moz-linear-gradient(top, #0C5DA5, #0C5DA5);
|
||||
background-image: -ms-linear-gradient(top, #0C5DA5, #0C5DA5);
|
||||
background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#0C5DA5), to(#0C5DA5));
|
||||
background-image: -webkit-linear-gradient(top, #0C5DA5, #0C5DA5);
|
||||
background-image: -o-linear-gradient(top, #0C5DA5, #0C5DA5);
|
||||
background-image: linear-gradient(top, #0C5DA5, #0C5DA5);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.calendar_calDayTodos .calendar_calDayTodosTable table td img {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
/*.background_color_10_gray;*/
|
||||
-webkit-box-shadow: 0px 1px 0px rgba(0, 0, 0, 0.5);
|
||||
-moz-box-shadow: 0px 1px 0px rgba(0, 0, 0, 0.5);
|
||||
box-shadow: 0px 1px 0px rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
-webkit-border-radius: 3px;
|
||||
-moz-border-radius: 3px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.calendar_calDayTodos .calendar_calDayTodosTable table td img:hover {
|
||||
/*.background_color_20_gray;*/
|
||||
-webkit-box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.6);
|
||||
-moz-box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.6);
|
||||
box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.6);
|
||||
border: 1px solid rgba(0, 0, 0, 0.5);
|
||||
-webkit-border-radius: 3px;
|
||||
-moz-border-radius: 3px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.calendar_calDayTodos .calendar_calDayTodosTable table td img:active {
|
||||
/*.background_color_30_gray;*/
|
||||
border: 1px solid rgba(0, 0, 0, 0.9);
|
||||
-webkit-border-radius: 3px;
|
||||
-moz-border-radius: 3px;
|
||||
border-radius: 3px;
|
||||
-webkit-box-shadow: inset 1px 2px 1px rgba(0, 0, 0, 0.5);
|
||||
-moz-box-shadow: inset 1px 2px 1px rgba(0, 0, 0, 0.5);
|
||||
box-shadow: inset 1px 2px 1px rgba(0, 0, 0, 0.5);
|
||||
background-color: #b3e4a6;
|
||||
background-color: #ace29e !important;
|
||||
}
|
||||
.calendar_calDayTodosHeader {
|
||||
text-align: center;
|
||||
|
@ -103,7 +103,7 @@
|
||||
box-shadow: none;
|
||||
}
|
||||
/* Hide email in sidebox */
|
||||
#calendar-sidebox_owner .title {
|
||||
#calendar-sidebox_owner sl-option::part(suffix) {
|
||||
display: none;
|
||||
}
|
||||
/* Conflict display */
|
||||
|
12
doc/README.md
Normal file
12
doc/README.md
Normal file
@ -0,0 +1,12 @@
|
||||
## Notes on automatic documentation
|
||||
|
||||
This is a project in itself. Here's how the pieces fit together:
|
||||
|
||||
+ `build:dev` package script calls `/doc/scripts/build.mjs` which is responsible for calling the individual pieces. We
|
||||
pass files to the subprocesses, but options are set in a separate config file.
|
||||
+ `/doc/scripts/metadata.mjs` extracts the component information
|
||||
using [CEM](https://custom-elements-manifest.open-wc.org/), and stores it to `/doc/dist/custom-elements.json`
|
||||
+ `/doc/scripts/etemplate2/eleventy.config.cjs` uses [11ty](11ty.dev) to build a documentation site, from the
|
||||
subdirectories in `/doc/etemplate2`, and stores it to `/doc/dist/site`
|
||||
|
||||
If a component doesn't show up, it's probably not in the manifest.
|
@ -202,7 +202,7 @@ curl https://example.org/egroupware/groupdav.php/<username>/calendar/ -H "Accept
|
||||
|
||||
following GET parameters are supported to customize the returned properties:
|
||||
- props[]=<DAV-prop-name> eg. props[]=getetag to return only the ETAG (multiple DAV properties can be specified)
|
||||
Default for addressbook collections is to only return address-data (JsContact), other collections return all props.
|
||||
Default for calendar collections is to only return calendar-data (JsEvent), other collections return all props.
|
||||
- sync-token=<token> to only request change since last sync-token, like rfc6578 sync-collection REPORT
|
||||
- nresults=N limit number of responses (only for sync-collection / given sync-token parameter!)
|
||||
this will return a "more-results"=true attribute and a new "sync-token" attribute to query for the next chunk
|
||||
@ -215,7 +215,7 @@ Examples: see addressbook
|
||||
<summary>Example: GET request for a single resource</summary>
|
||||
|
||||
```
|
||||
curl 'https://example.org/egroupware/groupdav.php/addressbook/6502' -H "Accept: application/pretty+json" --user <username>
|
||||
curl 'https://example.org/egroupware/groupdav.php/calendar/6502' -H "Accept: application/pretty+json" --user <username>
|
||||
{
|
||||
"@type": "Event",
|
||||
"prodId": "EGroupware Calendar 23.1.002",
|
||||
|
305
doc/etemplate2/_includes/component.njk
Normal file
305
doc/etemplate2/_includes/component.njk
Normal file
@ -0,0 +1,305 @@
|
||||
|
||||
{% extends "default.njk" %}
|
||||
|
||||
{# Find the component based on the `tag` front matter #}
|
||||
|
||||
{% set component = getComponent(component.tagName) %}
|
||||
|
||||
{% block content %}
|
||||
{# Determine the badge variant #}
|
||||
{% if component.status == 'stable' %}
|
||||
{% set badgeVariant = 'primary' %}
|
||||
{% elseif component.status == 'experimental' %}
|
||||
{% set badgeVariant = 'warning' %}
|
||||
{% elseif component.status == 'planned' %}
|
||||
{% set badgeVariant = 'neutral' %}
|
||||
{% elseif component.status == 'deprecated' %}
|
||||
{% set badgeVariant = 'danger' %}
|
||||
{% else %}
|
||||
{% set badgeVariant = 'neutral' %}
|
||||
{% endif %}
|
||||
|
||||
{# Header #}
|
||||
<header class="component-header">
|
||||
<h1>{{ component.name | classNameToComponentName }}</h1>
|
||||
|
||||
<div class="component-header__tag">
|
||||
<code><{{ component.tagName }}> | {{ component.name }}</code>
|
||||
</div>
|
||||
|
||||
<div class="component-header__info">
|
||||
<sl-badge variant="neutral" pill>
|
||||
Since {{component.since or '?' }}
|
||||
</sl-badge>
|
||||
<sl-badge variant="{{ badgeVariant }}" pill style="text-transform: capitalize;">
|
||||
{{ component.status }}
|
||||
</sl-badge>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p class="component-summary">
|
||||
{% if component.summary %}
|
||||
{{ component.summary | markdownInline | safe }}
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
{# Markdown content #}
|
||||
{{ content | safe }}
|
||||
|
||||
{# Slots #}
|
||||
{% if component.slots.length %}
|
||||
<h2>Slots</h2>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="table-name">Name</th>
|
||||
<th class="table-description">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for slot in component.slots %}
|
||||
<tr>
|
||||
<td class="nowrap">
|
||||
{% if slot.name %}
|
||||
<code>{{ slot.name }}</code>
|
||||
{% else %}
|
||||
(default)
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ slot.description | markdownInline | safe }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p><em>Learn more about <a href="{{ rootUrl('/getting-started/usage#slots') }}">using slots</a>.</em></p>
|
||||
{% endif %}
|
||||
|
||||
{# Properties #}
|
||||
{% if component.properties.length %}
|
||||
<h2>Properties</h2>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="table-name">Name</th>
|
||||
<th class="table-description">Description</th>
|
||||
<th class="table-reflects">Reflects</th>
|
||||
<th class="table-type">Type</th>
|
||||
<th class="table-default">Default</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for prop in component.properties %}
|
||||
<tr>
|
||||
<td>
|
||||
<code class="nowrap">{{ prop.name }}</code>
|
||||
{% if prop.attribute != prop.name %}
|
||||
<br>
|
||||
<sl-tooltip content="This attribute is different from its property">
|
||||
<small>
|
||||
<code class="nowrap">
|
||||
{{ prop.attribute }}
|
||||
</code>
|
||||
</small>
|
||||
</sl-tooltip>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{{ prop.description | markdownInline | safe }}
|
||||
</td>
|
||||
<td style="text-align: center;">
|
||||
{% if prop.reflects %}
|
||||
<sl-icon label="yes" name="check-lg"></sl-icon>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if prop.type.text %}
|
||||
<code>{{ prop.type.text | markdownInline | safe }}</code>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if prop.default %}
|
||||
<code>{{ prop.default | markdownInline | safe }}</code>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr>
|
||||
<td class="nowrap"><code>updateComplete</code></td>
|
||||
<td>
|
||||
A read-only promise that resolves when the component has
|
||||
<a href="/getting-started/usage?#component-rendering-and-updating">finished updating</a>.
|
||||
</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p><em>Learn more about <a href="{{ rootUrl('/getting-started/usage#properties') }}">attributes and properties</a>.</em></p>
|
||||
{% endif %}
|
||||
|
||||
{# Events #}
|
||||
{% if component.events.length %}
|
||||
<h2>Events</h2>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="table-name" data-flavor="html">Name</th>
|
||||
<th class="table-name" data-flavor="react">React Event</th>
|
||||
<th class="table-description">Description</th>
|
||||
<th class="table-event-detail">Event Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for event in component.events %}
|
||||
<tr>
|
||||
<td data-flavor="html"><code class="nowrap">{{ event.name }}</code></td>
|
||||
<td data-flavor="react"><code class="nowrap">{{ event.reactName }}</code></td>
|
||||
<td>{{ event.description | markdownInline | safe }}</td>
|
||||
<td>
|
||||
{% if event.type.text %}
|
||||
<code>{{ event.type.text }}</code>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p><em>Learn more about <a href="{{ rootUrl('/getting-started/usage#events') }}">events</a>.</em></p>
|
||||
{% endif %}
|
||||
|
||||
{# Methods #}
|
||||
{% if component.methods.length %}
|
||||
<h2>Methods</h2>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="table-name">Name</th>
|
||||
<th class="table-description">Description</th>
|
||||
<th class="table-arguments">Arguments</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for method in component.methods %}
|
||||
<tr>
|
||||
<td class="nowrap"><code>{{ method.name }}()</code></td>
|
||||
<td>{{ method.description | markdownInline | safe }}</td>
|
||||
<td>
|
||||
{% if method.parameters.length %}
|
||||
<code>
|
||||
{% for param in method.parameters %}
|
||||
{{ param.name }}: {{ param.type.text }}{% if not loop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
</code>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p><em>Learn more about <a href="{{ rootUrl('/getting-started/usage#methods') }}">methods</a>.</em></p>
|
||||
{% endif %}
|
||||
|
||||
{# Custom Properties #}
|
||||
{% if component.cssProperties.length %}
|
||||
<h2>Custom Properties</h2>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="table-name">Name</th>
|
||||
<th class="table-description">Description</th>
|
||||
<th class="table-default">Default</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for cssProperty in component.cssProperties %}
|
||||
<tr>
|
||||
<td class="nowrap"><code>{{ cssProperty.name }}</code></td>
|
||||
<td>{{ cssProperty.description | markdownInline | safe }}</td>
|
||||
<td>{{ cssProperty.default }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p><em>Learn more about <a href="{{ rootUrl('/getting-started/usage#custom-properties') }}">customizing CSS custom properties</a>.</em></p>
|
||||
{% endif %}
|
||||
|
||||
{# CSS Parts #}
|
||||
{% if component.cssParts.length %}
|
||||
<h2>Parts</h2>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="table-name">Name</th>
|
||||
<th class="table-description">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for cssPart in component.cssParts %}
|
||||
<tr>
|
||||
<td class="nowrap"><code>{{ cssPart.name }}</code></td>
|
||||
<td>{{ cssPart.description | markdownInline | safe }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p><em>Learn more about <a href="{{ rootUrl('/getting-started/usage#component-parts') }}">customizing CSS parts</a>.</em></p>
|
||||
{% endif %}
|
||||
|
||||
{# Animations #}
|
||||
{% if component.animations.length %}
|
||||
<h2>Animations</h2>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="table-name">Name</th>
|
||||
<th class="table-description">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for animation in component.animations %}
|
||||
<tr>
|
||||
<td class="nowrap"><code>{{ animation.name }}</code></td>
|
||||
<td>{{ animation.description | markdownInline | safe }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p><em>Learn more about <a href="{{ rootUrl('/getting-started/usage#animations') }}">customizing animations</a>.</em></p>
|
||||
{% endif %}
|
||||
|
||||
{# Dependencies #}
|
||||
{% if component.dependencies.length %}
|
||||
<h2>Dependencies</h2>
|
||||
|
||||
<p>This component automatically imports the following dependencies.</p>
|
||||
|
||||
<ul>
|
||||
{% for dependency in component.dependencies %}
|
||||
<li><code><{{ dependency }}></code></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endblock %}
|
147
doc/etemplate2/_includes/default.njk
Normal file
147
doc/etemplate2/_includes/default.njk
Normal file
@ -0,0 +1,147 @@
|
||||
<!DOCTYPE html>
|
||||
<html
|
||||
lang="en"
|
||||
data-layout="{{ layout }}"
|
||||
data-egroupware-version="{{ meta.version }}"
|
||||
>
|
||||
<head>
|
||||
{# Metadata #}
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="{{ meta.description }}" />
|
||||
<title>{{ meta.title }}</title>
|
||||
|
||||
{# Opt out of Turbo caching #}
|
||||
<meta name="turbo-cache-control">
|
||||
|
||||
{# Stylesheets #}
|
||||
<!-- <link rel="stylesheet" href="{{ assetUrl('styles/monochrome.css') }}" /> -->
|
||||
<link rel="stylesheet" href="{{ assetUrl('styles/docs.css') }}" />
|
||||
<link rel="stylesheet" href="{{ assetUrl('styles/code-previews.css') }}" />
|
||||
<link rel="stylesheet" href="{{ assetUrl('styles/search.css') }}" />
|
||||
|
||||
{# Favicons #}
|
||||
<link rel="icon" href="{{ assetUrl('images/logo.svg') }}" type="image/x-icon" />
|
||||
|
||||
{# OpenGraph #}
|
||||
<meta property="og:url" content="{{ rootUrl(page.url, true) }}" />
|
||||
<meta property="og:title" content="{{ meta.title }}" />
|
||||
<meta property="og:description" content="{{ meta.description }}" />
|
||||
<meta property="og:image" content="{{ assetUrl(meta.image, true) }}" />
|
||||
|
||||
{# API Viewer Element #}
|
||||
<script type="module" src="https://jspm.dev/api-viewer-element"></script>
|
||||
|
||||
{# EGroupware #}
|
||||
<!--<script src="{{ assetUrl('scripts/etemplate/etemplate2.js') }}" type="module"></script>-->
|
||||
|
||||
{# Shoelace #}
|
||||
<link rel="stylesheet" href="{{ assetUrl('shoelace/themes/light.css') }}" />
|
||||
<link rel="stylesheet" href="{{ assetUrl('shoelace/themes/dark.css') }}" />
|
||||
<script type="module" src="{{ assetUrl('shoelace/shoelace.js') }}"></script>
|
||||
|
||||
{# Set the initial theme and menu states here to prevent flashing #}
|
||||
<script>
|
||||
(() => {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const theme = localStorage.getItem('theme') || 'auto';
|
||||
document.documentElement.classList.toggle('sl-theme-dark', theme === 'dark' || (theme === 'auto' && prefersDark));
|
||||
})();
|
||||
</script>
|
||||
|
||||
{# Turbo + Scroll positioning #}
|
||||
<script src="{{ assetUrl('scripts/turbo.js') }}" type="module"></script>
|
||||
<script src="{{ assetUrl('scripts/docs.js') }}" defer></script>
|
||||
<script src="{{ assetUrl('scripts/code-previews.js') }}" defer></script>
|
||||
<script src="{{ assetUrl('scripts/lunr.js') }}" defer></script>
|
||||
<script src="{{ assetUrl('scripts/search.js') }}" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<a id="skip-to-main" class="visually-hidden" href="#main-content" data-smooth-link="false">
|
||||
Skip to main content
|
||||
</a>
|
||||
|
||||
{# Menu toggle #}
|
||||
<button id="menu-toggle" type="button" aria-label="Menu">
|
||||
<svg width="148" height="148" viewBox="0 0 148 148" xmlns="http://www.w3.org/2000/svg">
|
||||
<g stroke="currentColor" stroke-width="18" fill="none" fill-rule="evenodd" stroke-linecap="round">
|
||||
<path d="M9.5 125.5h129M9.5 74.5h129M9.5 23.5h129"></path>
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{# Icon toolbar #}
|
||||
<div id="icon-toolbar">
|
||||
{# GitHub #}
|
||||
<a href="https://github.com/EGroupware/egroupware" title="View EGroupware on GitHub">
|
||||
<sl-icon name="github"></sl-icon>
|
||||
</a>
|
||||
|
||||
{# Theme selector #}
|
||||
<sl-dropdown id="theme-selector" placement="bottom-end" distance="3">
|
||||
<sl-button slot="trigger" size="small" variant="text" caret title="Press \ to toggle">
|
||||
<sl-icon class="only-light" name="sun-fill"></sl-icon>
|
||||
<sl-icon class="only-dark" name="moon-fill"></sl-icon>
|
||||
</sl-button>
|
||||
<sl-menu>
|
||||
<sl-menu-item type="checkbox" value="light">Light</sl-menu-item>
|
||||
<sl-menu-item type="checkbox" value="dark">Dark</sl-menu-item>
|
||||
<sl-divider></sl-divider>
|
||||
<sl-menu-item type="checkbox" value="auto">System</sl-menu-item>
|
||||
</sl-menu>
|
||||
</sl-dropdown>
|
||||
</div>
|
||||
|
||||
<aside id="sidebar" data-preserve-scroll>
|
||||
<header>
|
||||
<a href="/">
|
||||
<img src="{{ assetUrl('images/logo.svg') }}" alt="EGroupware" />
|
||||
</a>
|
||||
<div class="sidebar-version">
|
||||
{{ meta.version }}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="sidebar-buttons">
|
||||
<sl-button size="small" class="repo-button repo-button--github" href="https://github.com/EGroupware/egroupware" target="_blank">
|
||||
<sl-icon slot="prefix" name="github"></sl-icon> Code
|
||||
</sl-button>
|
||||
<sl-button size="small" class="repo-button repo-button--star" href="https://github.com/EGroupware/egroupware/stargazers" target="_blank">
|
||||
<sl-icon slot="prefix" name="star-fill"></sl-icon> Star
|
||||
</sl-button>
|
||||
<sl-button size="small" class="repo-button repo-button--twitter" href="https://egroupware.org" target="_blank">
|
||||
<sl-icon slot="prefix" name="box-arrow-up-right"></sl-icon> EGroupware
|
||||
</sl-button>
|
||||
</div>
|
||||
|
||||
<button class="search-box" type="button" title="Press / to search" aria-label="Search" data-plugin="search">
|
||||
<sl-icon name="search"></sl-icon>
|
||||
<span>Search</span>
|
||||
</button>
|
||||
|
||||
<nav>
|
||||
{% include 'sidebar.njk' %}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{# Content #}
|
||||
<main>
|
||||
<a id="main-content"></a>
|
||||
<article id="content" class="content{% if toc %} content--with-toc{% endif %}">
|
||||
{% if toc %}
|
||||
<div class="content__toc">
|
||||
<ul>
|
||||
<li class="top"><a href="#">{{ meta.title }}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="content__body">
|
||||
{% block content %}
|
||||
{{ content | safe }}
|
||||
{% endblock %}
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
42
doc/etemplate2/_includes/sidebar.njk
Normal file
42
doc/etemplate2/_includes/sidebar.njk
Normal file
@ -0,0 +1,42 @@
|
||||
<ul>
|
||||
<li>
|
||||
<h2>Getting Started</h2>
|
||||
<ul>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="https://github.com/EGroupware/egroupware/wiki">Development</a></li>
|
||||
<li><a href="/getting-started/widgets">Widgets</a></li>
|
||||
<li><a href="/getting-started/styling">Styling</a></li>
|
||||
<li><a href="/getting-started/customizing">Customizing</a></li>
|
||||
<li><a href="/getting-started/localization">Localization</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<h2>Resources</h2>
|
||||
<ul>
|
||||
<li><a href="https://help.egroupware.org">Community</a></li>
|
||||
<li><a href="https://www.egroupware.org/en/professional-support">Help & Support</a></li>
|
||||
<li><a href="https://github.com/EGroupware/egroupware/releases">Changelog</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<h2>Components</h2>
|
||||
<ul>
|
||||
<li><em><a href="/components/sandbox">Sandbox</a></em></li>
|
||||
{% for component in meta.components %}
|
||||
<li>
|
||||
<a href="/components/{{ component.tagName | removeSlPrefix }}">
|
||||
{{ component.name | classNameToComponentName }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<h2>Tutorials</h2>
|
||||
<ul>
|
||||
<li><a href="/tutorials/creating-a-widget">Creating a widget</a></li>
|
||||
<li><a href="/tutorials/integrating-with-egw">Integrating with EGroupware</a></li>
|
||||
<li><a href="/tutorials/link-system">Implementing the linking system</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
35
doc/etemplate2/_utilities/active-links.cjs
Normal file
35
doc/etemplate2/_utilities/active-links.cjs
Normal file
@ -0,0 +1,35 @@
|
||||
function normalizePathname(pathname) {
|
||||
// Remove /index.html
|
||||
if (pathname.endsWith('/index.html')) {
|
||||
pathname = pathname.replace(/\/index\.html/, '');
|
||||
}
|
||||
|
||||
// Remove trailing slashes
|
||||
return pathname.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a class name to links that are currently active.
|
||||
*/
|
||||
module.exports = function (doc, options) {
|
||||
options = {
|
||||
className: 'active-link', // the class to add to active links
|
||||
pathname: undefined, // the current pathname to compare
|
||||
within: 'body', // element containing the target links
|
||||
...options
|
||||
};
|
||||
|
||||
const within = doc.querySelector(options.within);
|
||||
|
||||
if (!within) {
|
||||
return doc;
|
||||
}
|
||||
|
||||
within.querySelectorAll('a').forEach(link => {
|
||||
if (normalizePathname(options.pathname) === normalizePathname(link.pathname)) {
|
||||
link.classList.add(options.className);
|
||||
}
|
||||
});
|
||||
|
||||
return doc;
|
||||
};
|
64
doc/etemplate2/_utilities/anchor-headings.cjs
Normal file
64
doc/etemplate2/_utilities/anchor-headings.cjs
Normal file
@ -0,0 +1,64 @@
|
||||
const { createSlug } = require('./strings.cjs');
|
||||
|
||||
/**
|
||||
* Turns headings into clickable, deep linkable anchors. The provided doc should be a document object provided by JSDOM.
|
||||
* The same document will be returned with the appropriate DOM manipulations.
|
||||
*/
|
||||
module.exports = function (doc, options) {
|
||||
options = {
|
||||
levels: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'], // the headings to convert
|
||||
className: 'anchor-heading', // the class name to add
|
||||
within: 'body', // the element containing the target headings
|
||||
...options
|
||||
};
|
||||
|
||||
const within = doc.querySelector(options.within);
|
||||
|
||||
if (!within) {
|
||||
return doc;
|
||||
}
|
||||
|
||||
within.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach(heading => {
|
||||
const hasAnchor = heading.querySelector('a');
|
||||
const anchor = doc.createElement('a');
|
||||
let id = heading.textContent ?? '';
|
||||
let suffix = 0;
|
||||
|
||||
// Skip heading levels we don't care about
|
||||
if (!options.levels?.includes(heading.tagName.toLowerCase())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert dots to underscores
|
||||
id = id.replace(/\./g, '_');
|
||||
|
||||
// Turn it into a slug
|
||||
id = createSlug(id);
|
||||
|
||||
// Make sure it starts with a letter
|
||||
if (!/^[a-z]/i.test(id)) {
|
||||
id = `id_${id}`;
|
||||
}
|
||||
|
||||
// Make sure the id is unique
|
||||
const originalId = id;
|
||||
while (doc.getElementById(id) !== null) {
|
||||
id = `${originalId}-${++suffix}`;
|
||||
}
|
||||
|
||||
if (hasAnchor || !id) return;
|
||||
|
||||
heading.setAttribute('id', id);
|
||||
anchor.setAttribute('href', `#${encodeURIComponent(id)}`);
|
||||
anchor.setAttribute('aria-label', `Direct link to "${heading.textContent}"`);
|
||||
|
||||
if (options.className) {
|
||||
heading.classList.add(options.className);
|
||||
}
|
||||
|
||||
// Append the anchor
|
||||
heading.append(anchor);
|
||||
});
|
||||
|
||||
return doc;
|
||||
};
|
89
doc/etemplate2/_utilities/cem.cjs
Normal file
89
doc/etemplate2/_utilities/cem.cjs
Normal file
@ -0,0 +1,89 @@
|
||||
const customElementsManifest = require('../../dist/custom-elements.json');
|
||||
|
||||
//
|
||||
// Export it here so we can import it elsewhere and use the same version
|
||||
//
|
||||
module.exports.customElementsManifest = customElementsManifest;
|
||||
|
||||
//
|
||||
// Gets all components from custom-elements.json and returns them in a more documentation-friendly format.
|
||||
//
|
||||
module.exports.getAllComponents = function ()
|
||||
{
|
||||
const allComponents = [];
|
||||
|
||||
customElementsManifest.modules?.forEach(module =>
|
||||
{
|
||||
module.declarations?.forEach(declaration =>
|
||||
{
|
||||
if (declaration.customElement)
|
||||
{
|
||||
// Generate the dist path based on the src path and attach it to the component
|
||||
declaration.path = module.path.replace(/^src\//, 'dist/').replace(/\.ts$/, '.js');
|
||||
|
||||
// Remove members that are private or don't have a description
|
||||
const members = declaration.members?.filter(member => member.description && member.privacy !== 'private');
|
||||
const methods = members?.filter(prop => prop.kind === 'method' && prop.privacy !== 'private');
|
||||
const properties = members?.filter(prop =>
|
||||
{
|
||||
// Look for a corresponding attribute
|
||||
const attribute = declaration.attributes?.find(attr => attr.fieldName === prop.name);
|
||||
if (attribute)
|
||||
{
|
||||
prop.attribute = attribute.name || attribute.fieldName;
|
||||
}
|
||||
|
||||
return prop.kind === 'field' && prop.privacy !== 'private';
|
||||
});
|
||||
allComponents.push({
|
||||
...declaration,
|
||||
methods,
|
||||
properties
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Build dependency graphs
|
||||
allComponents.forEach(component =>
|
||||
{
|
||||
const dependencies = [];
|
||||
|
||||
// Recursively fetch sub-dependencies
|
||||
function getDependencies(tag)
|
||||
{
|
||||
const cmp = allComponents.find(c => c.tagName === tag);
|
||||
if (!cmp || !Array.isArray(component.dependencies))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
cmp.dependencies?.forEach(dependentTag =>
|
||||
{
|
||||
if (!dependencies.includes(dependentTag))
|
||||
{
|
||||
dependencies.push(dependentTag);
|
||||
}
|
||||
getDependencies(dependentTag);
|
||||
});
|
||||
}
|
||||
|
||||
getDependencies(component.tagName);
|
||||
|
||||
component.dependencies = dependencies.sort();
|
||||
});
|
||||
|
||||
// Sort by name
|
||||
return allComponents.sort((a, b) =>
|
||||
{
|
||||
if (a.name < b.name)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
if (a.name > b.name)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
};
|
138
doc/etemplate2/_utilities/code-previews.cjs
Normal file
138
doc/etemplate2/_utilities/code-previews.cjs
Normal file
@ -0,0 +1,138 @@
|
||||
let count = 1;
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns code fields with the :preview suffix into interactive code previews.
|
||||
*/
|
||||
module.exports = function (doc, options) {
|
||||
options = {
|
||||
within: 'body', // the element containing the code fields to convert
|
||||
...options
|
||||
};
|
||||
|
||||
const within = doc.querySelector(options.within);
|
||||
if (!within) {
|
||||
return doc;
|
||||
}
|
||||
|
||||
within.querySelectorAll('[class*=":preview"]').forEach(code => {
|
||||
const pre = code.closest('pre');
|
||||
if (!pre) {
|
||||
return;
|
||||
}
|
||||
const adjacentPre = pre.nextElementSibling?.tagName.toLowerCase() === 'pre' ? pre.nextElementSibling : null;
|
||||
const reactCode = adjacentPre?.querySelector('code[class$="react"]');
|
||||
const sourceGroupId = `code-preview-source-group-${count}`;
|
||||
const isExpanded = code.getAttribute('class').includes(':expanded');
|
||||
const noCodePen = code.getAttribute('class').includes(':no-codepen');
|
||||
|
||||
count++;
|
||||
|
||||
const htmlButton = `
|
||||
<button type="button"
|
||||
title="Show HTML code"
|
||||
class="code-preview__button code-preview__button--html"
|
||||
>
|
||||
HTML
|
||||
</button>
|
||||
`;
|
||||
|
||||
const reactButton = `
|
||||
<button type="button" title="Show React code" class="code-preview__button code-preview__button--react">
|
||||
React
|
||||
</button>
|
||||
`;
|
||||
|
||||
const codePenButton = `
|
||||
<button type="button" class="code-preview__button code-preview__button--codepen" title="Edit on CodePen">
|
||||
<svg
|
||||
width="138"
|
||||
height="26"
|
||||
viewBox="0 0 138 26"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M80 6h-9v14h9 M114 6h-9 v14h9 M111 13h-6 M77 13h-6 M122 20V6l11 14V6 M22 16.7L33 24l11-7.3V9.3L33 2L22 9.3V16.7z M44 16.7L33 9.3l-11 7.4 M22 9.3l11 7.3 l11-7.3 M33 2v7.3 M33 16.7V24 M88 14h6c2.2 0 4-1.8 4-4s-1.8-4-4-4h-6v14 M15 8c-1.3-1.3-3-2-5-2c-4 0-7 3-7 7s3 7 7 7 c2 0 3.7-0.8 5-2 M64 13c0 4-3 7-7 7h-5V6h5C61 6 64 9 64 13z" />
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
|
||||
const codePreview = `
|
||||
<div class="code-preview ${isExpanded ? 'code-preview--expanded' : ''}">
|
||||
<div class="code-preview__preview">
|
||||
${code.textContent}
|
||||
<div class="code-preview__resizer">
|
||||
<sl-icon name="grip-vertical"></sl-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="code-preview__source-group" id="${sourceGroupId}">
|
||||
<div class="code-preview__source code-preview__source--html" ${reactCode ? 'data-flavor="html"' : ''}>
|
||||
<pre><code class="language-html">${escapeHtml(code.textContent)}</code></pre>
|
||||
</div>
|
||||
|
||||
${
|
||||
reactCode
|
||||
? `
|
||||
<div class="code-preview__source code-preview__source--react" data-flavor="react">
|
||||
<pre><code class="language-jsx">${escapeHtml(reactCode.textContent)}</code></pre>
|
||||
</div>
|
||||
`
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="code-preview__buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="code-preview__button code-preview__toggle"
|
||||
aria-expanded="${isExpanded ? 'true' : 'false'}"
|
||||
aria-controls="${sourceGroupId}"
|
||||
>
|
||||
Source
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
${reactCode ? ` ${htmlButton} ${reactButton} ` : ''}
|
||||
|
||||
${noCodePen ? '' : codePenButton}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
pre.insertAdjacentHTML('afterend', codePreview);
|
||||
pre.remove();
|
||||
|
||||
if (adjacentPre) {
|
||||
adjacentPre.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// Wrap code preview scripts in anonymous functions so they don't run in the global scope
|
||||
doc.querySelectorAll('.code-preview__preview script').forEach(script => {
|
||||
if (script.type === 'module') {
|
||||
// Modules are already scoped
|
||||
script.textContent = script.innerHTML;
|
||||
} else {
|
||||
// Wrap non-modules in an anonymous function so they don't run in the global scope
|
||||
script.textContent = `(() => { ${script.innerHTML} })();`;
|
||||
}
|
||||
});
|
||||
|
||||
return doc;
|
||||
};
|
26
doc/etemplate2/_utilities/copy-code-buttons.cjs
Normal file
26
doc/etemplate2/_utilities/copy-code-buttons.cjs
Normal file
@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Adds copy code buttons to code fields. The provided doc should be a document object provided by JSDOM. The same
|
||||
* document will be returned with the appropriate DOM manipulations.
|
||||
*/
|
||||
module.exports = function (doc) {
|
||||
doc.querySelectorAll('pre > code').forEach(code => {
|
||||
const pre = code.closest('pre');
|
||||
const button = doc.createElement('button');
|
||||
button.setAttribute('type', 'button');
|
||||
button.classList.add('copy-code-button');
|
||||
button.setAttribute('aria-label', 'Copy');
|
||||
button.innerHTML = `
|
||||
<svg class="copy-code-button__copy-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-files" viewBox="0 0 16 16" part="svg">
|
||||
<path d="M13 0H6a2 2 0 0 0-2 2 2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2 2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm0 13V4a2 2 0 0 0-2-2H5a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1zM3 4a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4z"></path>
|
||||
</svg>
|
||||
|
||||
<svg class="copy-code-button__copied-icon" style="display: none;" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check-lg" viewBox="0 0 16 16" part="svg">
|
||||
<path d="M12.736 3.97a.733.733 0 0 1 1.047 0c.286.289.29.756.01 1.05L7.88 12.01a.733.733 0 0 1-1.065.02L3.217 8.384a.757.757 0 0 1 0-1.06.733.733 0 0 1 1.047 0l3.052 3.093 5.4-6.425a.247.247 0 0 1 .02-.022Z"></path>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
pre.append(button);
|
||||
});
|
||||
|
||||
return doc;
|
||||
};
|
41
doc/etemplate2/_utilities/external-links.cjs
Normal file
41
doc/etemplate2/_utilities/external-links.cjs
Normal file
@ -0,0 +1,41 @@
|
||||
const { isExternalLink } = require('./strings.cjs');
|
||||
|
||||
/**
|
||||
* Transforms external links to make them safer and optionally add a target. The provided doc should be a document
|
||||
* object provided by JSDOM. The same document will be returned with the appropriate DOM manipulations.
|
||||
*/
|
||||
module.exports = function (doc, options) {
|
||||
options = {
|
||||
className: 'external-link', // the class name to add to links
|
||||
noopener: true, // sets rel="noopener"
|
||||
noreferrer: true, // sets rel="noreferrer"
|
||||
ignore: () => false, // callback function to filter links that should be ignored
|
||||
within: 'body', // element that contains the target links
|
||||
target: '', // sets the target attribute
|
||||
...options
|
||||
};
|
||||
|
||||
const within = doc.querySelector(options.within);
|
||||
|
||||
if (within) {
|
||||
within.querySelectorAll('a').forEach(link => {
|
||||
if (isExternalLink(link) && !options.ignore(link)) {
|
||||
link.classList.add(options.className);
|
||||
|
||||
const rel = [];
|
||||
if (options.noopener) rel.push('noopener');
|
||||
if (options.noreferrer) rel.push('noreferrer');
|
||||
|
||||
if (rel.length) {
|
||||
link.setAttribute('rel', rel.join(' '));
|
||||
}
|
||||
|
||||
if (options.target) {
|
||||
link.setAttribute('target', options.target);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return doc;
|
||||
};
|
63
doc/etemplate2/_utilities/highlight-code.cjs
Normal file
63
doc/etemplate2/_utilities/highlight-code.cjs
Normal file
@ -0,0 +1,63 @@
|
||||
const Prism = require('prismjs');
|
||||
const PrismLoader = require('prismjs/components/index.js');
|
||||
|
||||
PrismLoader('diff');
|
||||
PrismLoader.silent = true;
|
||||
|
||||
/** Highlights a code string. */
|
||||
function highlight(code, language) {
|
||||
const alias = language.replace(/^diff-/, '');
|
||||
const isDiff = /^diff-/i.test(language);
|
||||
|
||||
// Auto-load the target language
|
||||
if (!Prism.languages[alias]) {
|
||||
PrismLoader(alias);
|
||||
|
||||
if (!Prism.languages[alias]) {
|
||||
throw new Error(`Unsupported language for code highlighting: "${language}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// Register diff-* languages to use the diff grammar
|
||||
if (isDiff) {
|
||||
Prism.languages[language] = Prism.languages.diff;
|
||||
}
|
||||
|
||||
return Prism.highlight(code, Prism.languages[language], language);
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlights all code fields that have a language parameter. If the language has a colon in its name, the first chunk
|
||||
* will be the language used and additional chunks will be applied as classes to the `<pre>`. For example, a code field
|
||||
* tagged with "html:preview" will be rendered as `<pre class="language-html preview">`.
|
||||
*
|
||||
* The provided doc should be a document object provided by JSDOM. The same document will be returned with the
|
||||
* appropriate DOM manipulations.
|
||||
*/
|
||||
module.exports = function (doc) {
|
||||
doc.querySelectorAll('pre > code[class]').forEach(code => {
|
||||
// Look for class="language-*" and split colons into separate classes
|
||||
code.classList.forEach(className => {
|
||||
if (className.startsWith('language-')) {
|
||||
//
|
||||
// We use certain suffixes to indicate code previews, expanded states, etc. The class might look something like
|
||||
// this:
|
||||
//
|
||||
// class="language-html:preview:expanded"
|
||||
//
|
||||
// The language will always come first, so we need to drop the "language-" prefix and everything after the first
|
||||
// color to get the highlighter language.
|
||||
//
|
||||
const language = className.replace(/^language-/, '').split(':')[0];
|
||||
|
||||
try {
|
||||
code.innerHTML = highlight(code.textContent ?? '', language);
|
||||
} catch (err) {
|
||||
// Language not found, skip it
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return doc;
|
||||
};
|
67
doc/etemplate2/_utilities/markdown.cjs
Normal file
67
doc/etemplate2/_utilities/markdown.cjs
Normal file
@ -0,0 +1,67 @@
|
||||
const MarkdownIt = require('markdown-it');
|
||||
const markdownItContainer = require('markdown-it-container');
|
||||
const markdownItIns = require('markdown-it-ins');
|
||||
const markdownItKbd = require('markdown-it-kbd');
|
||||
const markdownItMark = require('markdown-it-mark');
|
||||
const markdownItReplaceIt = require('markdown-it-replace-it');
|
||||
|
||||
const markdown = MarkdownIt({
|
||||
html: true,
|
||||
xhtmlOut: false,
|
||||
breaks: false,
|
||||
langPrefix: 'language-',
|
||||
linkify: false,
|
||||
typographer: false
|
||||
});
|
||||
|
||||
// Third-party plugins
|
||||
markdown.use(markdownItContainer);
|
||||
markdown.use(markdownItIns);
|
||||
markdown.use(markdownItKbd);
|
||||
markdown.use(markdownItMark);
|
||||
markdown.use(markdownItReplaceIt);
|
||||
|
||||
// Callouts
|
||||
['tip', 'warning', 'danger'].forEach(type => {
|
||||
markdown.use(markdownItContainer, type, {
|
||||
render: function (tokens, idx) {
|
||||
if (tokens[idx].nesting === 1) {
|
||||
return `<div role="alert" class="callout callout--${type}">`;
|
||||
}
|
||||
return '</div>\n';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Asides
|
||||
markdown.use(markdownItContainer, 'aside', {
|
||||
render: function (tokens, idx) {
|
||||
if (tokens[idx].nesting === 1) {
|
||||
return `<aside>`;
|
||||
}
|
||||
return '</aside>\n';
|
||||
}
|
||||
});
|
||||
|
||||
// Details
|
||||
markdown.use(markdownItContainer, 'details', {
|
||||
validate: params => params.trim().match(/^details\s+(.*)$/),
|
||||
render: (tokens, idx) => {
|
||||
const m = tokens[idx].info.trim().match(/^details\s+(.*)$/);
|
||||
if (tokens[idx].nesting === 1) {
|
||||
return `<details>\n<summary><span>${markdown.utils.escapeHtml(m[1])}</span></summary>\n`;
|
||||
}
|
||||
return '</details>\n';
|
||||
}
|
||||
});
|
||||
|
||||
// Replace [#1234] with a link to GitHub issues
|
||||
markdownItReplaceIt.replacements.push({
|
||||
name: 'github-issues',
|
||||
re: /\[#([0-9]+)\]/gs,
|
||||
sub: '<a href="https://github.com/shoelace-style/shoelace/issues/$1">#$1</a>',
|
||||
html: true,
|
||||
default: true
|
||||
});
|
||||
|
||||
module.exports = markdown;
|
27
doc/etemplate2/_utilities/prettier.cjs
Normal file
27
doc/etemplate2/_utilities/prettier.cjs
Normal file
@ -0,0 +1,27 @@
|
||||
const {format} = require('prettier');
|
||||
|
||||
/** Formats markup using prettier. */
|
||||
module.exports = function (content, options)
|
||||
{
|
||||
options = {
|
||||
arrowParens: 'avoid',
|
||||
bracketSpacing: true,
|
||||
htmlWhitespaceSensitivity: 'css',
|
||||
insertPragma: false,
|
||||
bracketSameLine: false,
|
||||
jsxSingleQuote: false,
|
||||
parser: 'html',
|
||||
printWidth: 120,
|
||||
proseWrap: 'preserve',
|
||||
quoteProps: 'as-needed',
|
||||
requirePragma: false,
|
||||
semi: true,
|
||||
singleQuote: true,
|
||||
tabWidth: 2,
|
||||
trailingComma: 'none',
|
||||
useTabs: false,
|
||||
...options
|
||||
};
|
||||
|
||||
return format(content, options);
|
||||
};
|
19
doc/etemplate2/_utilities/replacer.cjs
Normal file
19
doc/etemplate2/_utilities/replacer.cjs
Normal file
@ -0,0 +1,19 @@
|
||||
/**
|
||||
* @typedef {object} Replacement
|
||||
* @property {string | RegExp} pattern
|
||||
* @property {string} replacement
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Array<Replacement>} Replacements
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {Document} content
|
||||
* @param {Replacements} replacements
|
||||
*/
|
||||
module.exports = function (content, replacements) {
|
||||
replacements.forEach(replacement => {
|
||||
content.body.innerHTML = content.body.innerHTML.replaceAll(replacement.pattern, replacement.replacement);
|
||||
});
|
||||
};
|
21
doc/etemplate2/_utilities/scrolling-tables.cjs
Normal file
21
doc/etemplate2/_utilities/scrolling-tables.cjs
Normal file
@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Turns headings into clickable, deep linkable anchors. The provided doc should be a document object provided by JSDOM.
|
||||
* The same document will be returned with the appropriate DOM manipulations.
|
||||
*/
|
||||
module.exports = function (doc, options) {
|
||||
const tables = [...doc.querySelectorAll('table')];
|
||||
|
||||
options = {
|
||||
className: 'table-scroll', // the class name to add to the table's container
|
||||
...options
|
||||
};
|
||||
|
||||
tables.forEach(table => {
|
||||
const div = doc.createElement('div');
|
||||
div.classList.add(options.className);
|
||||
table.insertAdjacentElement('beforebegin', div);
|
||||
div.append(table);
|
||||
});
|
||||
|
||||
return doc;
|
||||
};
|
16
doc/etemplate2/_utilities/strings.cjs
Normal file
16
doc/etemplate2/_utilities/strings.cjs
Normal file
@ -0,0 +1,16 @@
|
||||
const slugify = require('slugify');
|
||||
|
||||
/** Creates a slug from an arbitrary string of text. */
|
||||
module.exports.createSlug = function (text) {
|
||||
return slugify(String(text), {
|
||||
remove: /[^\w|\s]/g,
|
||||
lower: true
|
||||
});
|
||||
};
|
||||
|
||||
/** Determines whether or not a link is external. */
|
||||
module.exports.isExternalLink = function (link) {
|
||||
// We use the "internal" hostname when initializing JSDOM so we know that those are local links
|
||||
if (!link.hostname || link.hostname === 'internal') return false;
|
||||
return true;
|
||||
};
|
42
doc/etemplate2/_utilities/table-of-contents.cjs
Normal file
42
doc/etemplate2/_utilities/table-of-contents.cjs
Normal file
@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Generates an in-page table of contents based on headings.
|
||||
*/
|
||||
module.exports = function (doc, options) {
|
||||
options = {
|
||||
levels: ['h2'], // headings to include (they must have an id)
|
||||
container: 'nav', // the container to append links to
|
||||
listItem: true, // if true, links will be wrapped in <li>
|
||||
within: 'body', // the element containing the headings to summarize
|
||||
...options
|
||||
};
|
||||
|
||||
const container = doc.querySelector(options.container);
|
||||
const within = doc.querySelector(options.within);
|
||||
const headingSelector = options.levels.map(h => `${h}[id]`).join(', ');
|
||||
|
||||
if (!container || !within) {
|
||||
return doc;
|
||||
}
|
||||
|
||||
within.querySelectorAll(headingSelector).forEach(heading => {
|
||||
const listItem = doc.createElement('li');
|
||||
const link = doc.createElement('a');
|
||||
const level = heading.tagName.slice(1);
|
||||
|
||||
link.href = `#${heading.id}`;
|
||||
link.textContent = heading.textContent;
|
||||
|
||||
if (options.listItem) {
|
||||
// List item + link
|
||||
listItem.setAttribute('data-level', level);
|
||||
listItem.append(link);
|
||||
container.append(listItem);
|
||||
} else {
|
||||
// Link only
|
||||
link.setAttribute('data-level', level);
|
||||
container.append(link);
|
||||
}
|
||||
});
|
||||
|
||||
return doc;
|
||||
};
|
23
doc/etemplate2/_utilities/typography.cjs
Normal file
23
doc/etemplate2/_utilities/typography.cjs
Normal file
@ -0,0 +1,23 @@
|
||||
const smartquotes = require('smartquotes');
|
||||
|
||||
smartquotes.replacements.push([/---/g, '\u2014']); // em dash
|
||||
smartquotes.replacements.push([/--/g, '\u2013']); // en dash
|
||||
smartquotes.replacements.push([/\.\.\./g, '\u2026']); // ellipsis
|
||||
smartquotes.replacements.push([/\(c\)/gi, '\u00A9']); // copyright
|
||||
smartquotes.replacements.push([/\(r\)/gi, '\u00AE']); // registered trademark
|
||||
smartquotes.replacements.push([/\?!/g, '\u2048']); // ?!
|
||||
smartquotes.replacements.push([/!!/g, '\u203C']); // !!
|
||||
smartquotes.replacements.push([/\?\?/g, '\u2047']); // ??
|
||||
smartquotes.replacements.push([/([0-9]\s?)-(\s?[0-9])/g, '$1\u2013$2']); // number ranges use en dash
|
||||
|
||||
/**
|
||||
* Improves typography by adding smart quotes and similar corrections within the specified element(s).
|
||||
*
|
||||
* The provided doc should be a document object provided by JSDOM. The same document will be returned with the
|
||||
* appropriate DOM manipulations.
|
||||
*/
|
||||
module.exports = function (doc, selector = 'body') {
|
||||
const elements = [...doc.querySelectorAll(selector)];
|
||||
elements.forEach(el => smartquotes.element(el));
|
||||
return doc;
|
||||
};
|
19
doc/etemplate2/assets/examples/include.html
Normal file
19
doc/etemplate2/assets/examples/include.html
Normal file
@ -0,0 +1,19 @@
|
||||
<p style="margin-top: 0">
|
||||
The content in this example was included from
|
||||
<a href="/assets/examples/include.html" target="_blank">a separate file</a>. 🤯
|
||||
</p>
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna
|
||||
aliqua. Lectus vestibulum mattis ullamcorper velit sed ullamcorper morbi. Fringilla urna porttitor rhoncus dolor purus
|
||||
non enim. Nullam vehicula ipsum a arcu cursus vitae congue mauris. Gravida in fermentum et sollicitudin.
|
||||
</p>
|
||||
<p>
|
||||
Cursus sit amet dictum sit amet justo donec enim. Sed id semper risus in hendrerit gravida. Viverra accumsan in nisl
|
||||
nisi scelerisque eu ultrices vitae. Et molestie ac feugiat sed lectus vestibulum mattis ullamcorper velit. Nec
|
||||
ullamcorper sit amet risus nullam. Et egestas quis ipsum suspendisse ultrices gravida dictum. Lorem donec massa sapien
|
||||
faucibus et molestie. A cras semper auctor neque vitae.
|
||||
</p>
|
||||
|
||||
<script>
|
||||
console.log('This will only execute if the `allow-scripts` prop is present');
|
||||
</script>
|
BIN
doc/etemplate2/assets/images/widgets_rendered_example.png
Normal file
BIN
doc/etemplate2/assets/images/widgets_rendered_example.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.0 KiB |
286
doc/etemplate2/assets/scripts/code-previews.js
Normal file
286
doc/etemplate2/assets/scripts/code-previews.js
Normal file
@ -0,0 +1,286 @@
|
||||
(() =>
|
||||
{
|
||||
function convertModuleLinks(html)
|
||||
{
|
||||
html = html
|
||||
.replace(/@shoelace-style\/shoelace/g, `https://esm.sh/@shoelace-style/shoelace@${egwVersion}`)
|
||||
.replace(/from 'react'/g, `from 'https://esm.sh/react@${reactVersion}'`)
|
||||
.replace(/from "react"/g, `from "https://esm.sh/react@${reactVersion}"`);
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function getAdjacentExample(name, pre)
|
||||
{
|
||||
let currentPre = pre.nextElementSibling;
|
||||
|
||||
while (currentPre?.tagName.toLowerCase() === 'pre')
|
||||
{
|
||||
if (currentPre?.getAttribute('data-lang').split(' ').includes(name))
|
||||
{
|
||||
return currentPre;
|
||||
}
|
||||
|
||||
currentPre = currentPre.nextElementSibling;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function runScript(script)
|
||||
{
|
||||
const newScript = document.createElement('script');
|
||||
|
||||
if (script.type === 'module')
|
||||
{
|
||||
newScript.type = 'module';
|
||||
newScript.textContent = script.innerHTML;
|
||||
}
|
||||
else
|
||||
{
|
||||
newScript.appendChild(document.createTextNode(`(() => { ${script.innerHTML} })();`));
|
||||
}
|
||||
|
||||
script.parentNode.replaceChild(newScript, script);
|
||||
}
|
||||
|
||||
function getFlavor()
|
||||
{
|
||||
return sessionStorage.getItem('flavor') || 'html';
|
||||
}
|
||||
|
||||
function setFlavor(newFlavor)
|
||||
{
|
||||
flavor = ['html', 'react'].includes(newFlavor) ? newFlavor : 'html';
|
||||
sessionStorage.setItem('flavor', flavor);
|
||||
|
||||
// Set the flavor class on the body
|
||||
document.documentElement.classList.toggle('flavor-html', flavor === 'html');
|
||||
document.documentElement.classList.toggle('flavor-react', flavor === 'react');
|
||||
}
|
||||
|
||||
function syncFlavor()
|
||||
{
|
||||
setFlavor(getFlavor());
|
||||
|
||||
document.querySelectorAll('.code-preview__button--html').forEach(preview =>
|
||||
{
|
||||
if (flavor === 'html')
|
||||
{
|
||||
preview.classList.add('code-preview__button--selected');
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('.code-preview__button--react').forEach(preview =>
|
||||
{
|
||||
if (flavor === 'react')
|
||||
{
|
||||
preview.classList.add('code-preview__button--selected');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const egwVersion = document.documentElement.getAttribute('data-egroupware-version');
|
||||
const reactVersion = '18.2.0';
|
||||
const cdndir = 'cdn';
|
||||
const npmdir = 'dist';
|
||||
let flavor = getFlavor();
|
||||
let count = 1;
|
||||
|
||||
// We need the version to open
|
||||
if (!egwVersion)
|
||||
{
|
||||
throw new Error('The data-egroupware-version attribute is missing from <html>.');
|
||||
}
|
||||
|
||||
// Sync flavor UI on page load
|
||||
syncFlavor();
|
||||
|
||||
//
|
||||
// Resizing previews
|
||||
//
|
||||
document.addEventListener('mousedown', handleResizerDrag);
|
||||
document.addEventListener('touchstart', handleResizerDrag, {passive: true});
|
||||
|
||||
function handleResizerDrag(event)
|
||||
{
|
||||
const resizer = event.target.closest('.code-preview__resizer');
|
||||
const preview = event.target.closest('.code-preview__preview');
|
||||
|
||||
if (!resizer || !preview)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let startX = event.changedTouches ? event.changedTouches[0].pageX : event.clientX;
|
||||
let startWidth = parseInt(document.defaultView.getComputedStyle(preview).width, 10);
|
||||
|
||||
event.preventDefault();
|
||||
preview.classList.add('code-preview__preview--dragging');
|
||||
document.documentElement.addEventListener('mousemove', dragMove);
|
||||
document.documentElement.addEventListener('touchmove', dragMove);
|
||||
document.documentElement.addEventListener('mouseup', dragStop);
|
||||
document.documentElement.addEventListener('touchend', dragStop);
|
||||
|
||||
function dragMove(event)
|
||||
{
|
||||
const width = startWidth + (event.changedTouches ? event.changedTouches[0].pageX : event.pageX) - startX;
|
||||
preview.style.width = `${width}px`;
|
||||
}
|
||||
|
||||
function dragStop()
|
||||
{
|
||||
preview.classList.remove('code-preview__preview--dragging');
|
||||
document.documentElement.removeEventListener('mousemove', dragMove);
|
||||
document.documentElement.removeEventListener('touchmove', dragMove);
|
||||
document.documentElement.removeEventListener('mouseup', dragStop);
|
||||
document.documentElement.removeEventListener('touchend', dragStop);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Toggle source mode
|
||||
//
|
||||
document.addEventListener('click', event =>
|
||||
{
|
||||
const button = event.target.closest('.code-preview__button');
|
||||
const codeBlock = button?.closest('.code-preview');
|
||||
|
||||
if (button?.classList.contains('code-preview__button--html'))
|
||||
{
|
||||
// Show HTML
|
||||
setFlavor('html');
|
||||
toggleSource(codeBlock, true);
|
||||
}
|
||||
else if (button?.classList.contains('code-preview__button--react'))
|
||||
{
|
||||
// Show React
|
||||
setFlavor('react');
|
||||
toggleSource(codeBlock, true);
|
||||
}
|
||||
else if (button?.classList.contains('code-preview__toggle'))
|
||||
{
|
||||
// Toggle source
|
||||
toggleSource(codeBlock);
|
||||
}
|
||||
else
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Update flavor buttons
|
||||
[...document.querySelectorAll('.code-preview')].forEach(cb =>
|
||||
{
|
||||
cb.querySelector('.code-preview__button--html')?.classList.toggle(
|
||||
'code-preview__button--selected',
|
||||
flavor === 'html'
|
||||
);
|
||||
cb.querySelector('.code-preview__button--react')?.classList.toggle(
|
||||
'code-preview__button--selected',
|
||||
flavor === 'react'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function toggleSource(codeBlock, force)
|
||||
{
|
||||
codeBlock.classList.toggle('code-preview--expanded', force);
|
||||
event.target.setAttribute('aria-expanded', codeBlock.classList.contains('code-preview--expanded'));
|
||||
}
|
||||
|
||||
//
|
||||
// Open in CodePen
|
||||
//
|
||||
document.addEventListener('click', event =>
|
||||
{
|
||||
const button = event.target.closest('button');
|
||||
|
||||
if (button?.classList.contains('code-preview__button--codepen'))
|
||||
{
|
||||
const codeBlock = button.closest('.code-preview');
|
||||
const htmlExample = codeBlock.querySelector('.code-preview__source--html > pre > code')?.textContent;
|
||||
const reactExample = codeBlock.querySelector('.code-preview__source--react > pre > code')?.textContent;
|
||||
const isReact = flavor === 'react' && typeof reactExample === 'string';
|
||||
const theme = document.documentElement.classList.contains('sl-theme-dark') ? 'dark' : 'light';
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const isDark = theme === 'dark' || (theme === 'auto' && prefersDark);
|
||||
const editors = isReact ? '0010' : '1000';
|
||||
let htmlTemplate = '';
|
||||
let jsTemplate = '';
|
||||
let cssTemplate = '';
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.action = 'https://codepen.io/pen/define';
|
||||
form.method = 'POST';
|
||||
form.target = '_blank';
|
||||
|
||||
// HTML templates
|
||||
if (!isReact)
|
||||
{
|
||||
htmlTemplate =
|
||||
`<script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@${egwVersion}/${cdndir}/shoelace.js"></script>\n` +
|
||||
`\n${htmlExample}`;
|
||||
jsTemplate = '';
|
||||
}
|
||||
|
||||
// React templates
|
||||
if (isReact)
|
||||
{
|
||||
htmlTemplate = '<div id="root"></div>';
|
||||
jsTemplate =
|
||||
`import React from 'https://esm.sh/react@${reactVersion}';\n` +
|
||||
`import ReactDOM from 'https://esm.sh/react-dom@${reactVersion}';\n` +
|
||||
`import { setBasePath } from 'https://esm.sh/@shoelace-style/shoelace@${egwVersion}/${cdndir}/utilities/base-path';\n` +
|
||||
`\n` +
|
||||
`// Set the base path for Shoelace assets\n` +
|
||||
`setBasePath('https://esm.sh/@shoelace-style/shoelace@${egwVersion}/${npmdir}/')\n` +
|
||||
`\n${convertModuleLinks(reactExample)}\n` +
|
||||
`\n` +
|
||||
`ReactDOM.render(<App />, document.getElementById('root'));`;
|
||||
}
|
||||
|
||||
// CSS templates
|
||||
cssTemplate =
|
||||
`@import 'https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@${egwVersion}/${cdndir}/themes/${
|
||||
isDark ? 'dark' : 'light'
|
||||
}.css';\n` +
|
||||
'\n' +
|
||||
'body {\n' +
|
||||
' font: 16px sans-serif;\n' +
|
||||
' background-color: var(--sl-color-neutral-0);\n' +
|
||||
' color: var(--sl-color-neutral-900);\n' +
|
||||
' padding: 1rem;\n' +
|
||||
'}';
|
||||
|
||||
// Docs: https://blog.codepen.io/documentation/prefill/
|
||||
const data = {
|
||||
title: '',
|
||||
description: '',
|
||||
tags: ['shoelace', 'web components'],
|
||||
editors,
|
||||
head: `<meta name="viewport" content="width=device-width">`,
|
||||
html_classes: `sl-theme-${isDark ? 'dark' : 'light'}`,
|
||||
css_external: ``,
|
||||
js_external: ``,
|
||||
js_module: true,
|
||||
js_pre_processor: isReact ? 'babel' : 'none',
|
||||
html: htmlTemplate,
|
||||
css: cssTemplate,
|
||||
js: jsTemplate
|
||||
};
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'data';
|
||||
input.value = JSON.stringify(data);
|
||||
form.append(input);
|
||||
|
||||
document.documentElement.append(form);
|
||||
form.submit();
|
||||
form.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// Set the initial flavor
|
||||
window.addEventListener('turbo:load', syncFlavor);
|
||||
})();
|
298
doc/etemplate2/assets/scripts/docs.js
Normal file
298
doc/etemplate2/assets/scripts/docs.js
Normal file
@ -0,0 +1,298 @@
|
||||
//
|
||||
// Sidebar
|
||||
//
|
||||
// When the sidebar is hidden, we apply the inert attribute to prevent focus from reaching it. Due to the many states
|
||||
// the sidebar can have (e.g. static, hidden, expanded), we test for visibility by checking to see if it's placed
|
||||
// offscreen or not. Then, on resize/transition we make sure to update the attribute accordingly.
|
||||
//
|
||||
(() => {
|
||||
function getSidebar() {
|
||||
return document.getElementById('sidebar');
|
||||
}
|
||||
|
||||
function isSidebarOpen() {
|
||||
return document.documentElement.classList.contains('sidebar-open');
|
||||
}
|
||||
|
||||
function isSidebarVisible() {
|
||||
return getSidebar().getBoundingClientRect().x >= 0;
|
||||
}
|
||||
|
||||
function toggleSidebar(force) {
|
||||
const isOpen = typeof force === 'boolean' ? force : !isSidebarOpen();
|
||||
return document.documentElement.classList.toggle('sidebar-open', isOpen);
|
||||
}
|
||||
|
||||
function updateInert() {
|
||||
getSidebar().inert = !isSidebarVisible();
|
||||
}
|
||||
|
||||
// Toggle the menu
|
||||
document.addEventListener('click', event => {
|
||||
const menuToggle = event.target.closest('#menu-toggle');
|
||||
if (!menuToggle) return;
|
||||
toggleSidebar();
|
||||
});
|
||||
|
||||
// Update the sidebar's inert state when the window resizes and when the sidebar transitions
|
||||
window.addEventListener('resize', () => toggleSidebar(false));
|
||||
|
||||
document.addEventListener('transitionend', event => {
|
||||
const sidebar = event.target.closest('#sidebar');
|
||||
if (!sidebar) return;
|
||||
updateInert();
|
||||
});
|
||||
|
||||
// Close when a menu item is selected on mobile
|
||||
document.addEventListener('click', event => {
|
||||
const sidebar = event.target.closest('#sidebar');
|
||||
const link = event.target.closest('a');
|
||||
if (!sidebar || !link) return;
|
||||
|
||||
if (isSidebarOpen()) {
|
||||
toggleSidebar();
|
||||
}
|
||||
});
|
||||
|
||||
// Close when open and escape is pressed
|
||||
document.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape' && isSidebarOpen()) {
|
||||
event.stopImmediatePropagation();
|
||||
toggleSidebar();
|
||||
}
|
||||
});
|
||||
|
||||
// Close when clicking outside of the sidebar
|
||||
document.addEventListener('mousedown', event => {
|
||||
if (isSidebarOpen() & !event.target?.closest('#sidebar, #menu-toggle')) {
|
||||
event.stopImmediatePropagation();
|
||||
toggleSidebar();
|
||||
}
|
||||
});
|
||||
|
||||
updateInert();
|
||||
})();
|
||||
|
||||
//
|
||||
// Theme selector
|
||||
//
|
||||
(() => {
|
||||
function getTheme() {
|
||||
return localStorage.getItem('theme') || 'auto';
|
||||
}
|
||||
|
||||
function isDark() {
|
||||
if (theme === 'auto') {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
return theme === 'dark';
|
||||
}
|
||||
|
||||
function setTheme(newTheme) {
|
||||
theme = newTheme;
|
||||
localStorage.setItem('theme', theme);
|
||||
|
||||
// Update the UI
|
||||
updateSelection();
|
||||
|
||||
// Toggle the dark mode class
|
||||
document.documentElement.classList.toggle('sl-theme-dark', isDark());
|
||||
}
|
||||
|
||||
function updateSelection() {
|
||||
const menu = document.querySelector('#theme-selector sl-menu');
|
||||
if (!menu) return;
|
||||
[...menu.querySelectorAll('sl-menu-item')].map(item => (item.checked = item.getAttribute('value') === theme));
|
||||
}
|
||||
|
||||
let theme = getTheme();
|
||||
|
||||
// Selection is not preserved when changing page, so update when opening dropdown
|
||||
document.addEventListener('sl-show', event => {
|
||||
const themeSelector = event.target.closest('#theme-selector');
|
||||
if (!themeSelector) return;
|
||||
updateSelection();
|
||||
});
|
||||
|
||||
// Listen for selections
|
||||
document.addEventListener('sl-select', event => {
|
||||
const menu = event.target.closest('#theme-selector sl-menu');
|
||||
if (!menu) return;
|
||||
setTheme(event.detail.item.value);
|
||||
});
|
||||
|
||||
// Update the theme when the preference changes
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => setTheme(theme));
|
||||
|
||||
// Toggle with backslash
|
||||
document.addEventListener('keydown', event => {
|
||||
if (
|
||||
event.key === '\\' &&
|
||||
!event.composedPath().some(el => ['input', 'textarea'].includes(el?.tagName?.toLowerCase()))
|
||||
) {
|
||||
event.preventDefault();
|
||||
setTheme(isDark() ? 'light' : 'dark');
|
||||
}
|
||||
});
|
||||
|
||||
// Set the initial theme and sync the UI
|
||||
setTheme(theme);
|
||||
})();
|
||||
|
||||
//
|
||||
// Open details when printing
|
||||
//
|
||||
(() => {
|
||||
const detailsOpenOnPrint = new Set();
|
||||
|
||||
window.addEventListener('beforeprint', () => {
|
||||
detailsOpenOnPrint.clear();
|
||||
document.querySelectorAll('details').forEach(details => {
|
||||
if (details.open) {
|
||||
detailsOpenOnPrint.add(details);
|
||||
}
|
||||
details.open = true;
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('afterprint', () => {
|
||||
document.querySelectorAll('details').forEach(details => {
|
||||
details.open = detailsOpenOnPrint.has(details);
|
||||
});
|
||||
detailsOpenOnPrint.clear();
|
||||
});
|
||||
})();
|
||||
|
||||
//
|
||||
// Copy code buttons
|
||||
//
|
||||
(() => {
|
||||
document.addEventListener('click', event => {
|
||||
const button = event.target.closest('.copy-code-button');
|
||||
const pre = button?.closest('pre');
|
||||
const code = pre?.querySelector('code');
|
||||
const copyIcon = button?.querySelector('.copy-code-button__copy-icon');
|
||||
const copiedIcon = button?.querySelector('.copy-code-button__copied-icon');
|
||||
|
||||
if (button && code) {
|
||||
navigator.clipboard.writeText(code.innerText);
|
||||
copyIcon.style.display = 'none';
|
||||
copiedIcon.style.display = 'inline';
|
||||
button.classList.add('copy-code-button--copied');
|
||||
|
||||
setTimeout(() => {
|
||||
copyIcon.style.display = 'inline';
|
||||
copiedIcon.style.display = 'none';
|
||||
button.classList.remove('copy-code-button--copied');
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
//
|
||||
// Smooth links
|
||||
//
|
||||
(() => {
|
||||
document.addEventListener('click', event => {
|
||||
const link = event.target.closest('a');
|
||||
const id = (link?.hash ?? '').substr(1);
|
||||
const isFragment = link?.hasAttribute('href') && link?.getAttribute('href').startsWith('#');
|
||||
|
||||
if (!link || !isFragment || link.getAttribute('data-smooth-link') === 'false') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Scroll to the top
|
||||
if (link.hash === '') {
|
||||
event.preventDefault();
|
||||
window.scroll({ top: 0, behavior: 'smooth' });
|
||||
history.pushState(undefined, undefined, location.pathname);
|
||||
}
|
||||
|
||||
// Scroll to an id
|
||||
if (id) {
|
||||
const target = document.getElementById(id);
|
||||
|
||||
if (target) {
|
||||
event.preventDefault();
|
||||
window.scroll({ top: target.offsetTop, behavior: 'smooth' });
|
||||
history.pushState(undefined, undefined, `#${id}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
//
|
||||
// Table of Contents scrollspy
|
||||
//
|
||||
(() => {
|
||||
// This will be stale if its not a function.
|
||||
const getLinks = () => [...document.querySelectorAll('.content__toc a')];
|
||||
const linkTargets = new WeakMap();
|
||||
const visibleTargets = new WeakSet();
|
||||
const observer = new IntersectionObserver(handleIntersect, { rootMargin: '0px 0px' });
|
||||
let debounce;
|
||||
|
||||
function handleIntersect(entries) {
|
||||
entries.forEach(entry => {
|
||||
// Remember which targets are visible
|
||||
if (entry.isIntersecting) {
|
||||
visibleTargets.add(entry.target);
|
||||
} else {
|
||||
visibleTargets.delete(entry.target);
|
||||
}
|
||||
});
|
||||
|
||||
updateActiveLinks();
|
||||
}
|
||||
|
||||
function updateActiveLinks() {
|
||||
const links = getLinks();
|
||||
// Find the first visible target and activate the respective link
|
||||
links.find(link => {
|
||||
const target = linkTargets.get(link);
|
||||
|
||||
if (target && visibleTargets.has(target)) {
|
||||
links.forEach(el => el.classList.toggle('active', el === link));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// Observe link targets
|
||||
function observeLinks() {
|
||||
getLinks().forEach(link => {
|
||||
const hash = link.hash.slice(1);
|
||||
const target = hash ? document.querySelector(`.content__body #${hash}`) : null;
|
||||
|
||||
if (target) {
|
||||
linkTargets.set(link, target);
|
||||
observer.observe(target);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
observeLinks();
|
||||
|
||||
document.addEventListener('turbo:load', updateActiveLinks);
|
||||
document.addEventListener('turbo:load', observeLinks);
|
||||
})();
|
||||
|
||||
//
|
||||
// Show custom versions in the sidebar
|
||||
//
|
||||
(() => {
|
||||
function updateVersion() {
|
||||
const el = document.querySelector('.sidebar-version');
|
||||
if (!el) return;
|
||||
|
||||
if (location.hostname === 'next.shoelace.style') el.textContent = 'Next';
|
||||
if (location.hostname === 'localhost') el.textContent = 'Development';
|
||||
}
|
||||
|
||||
updateVersion();
|
||||
|
||||
document.addEventListener('turbo:load', updateVersion);
|
||||
})();
|
376
doc/etemplate2/assets/scripts/search.js
Normal file
376
doc/etemplate2/assets/scripts/search.js
Normal file
@ -0,0 +1,376 @@
|
||||
(() => {
|
||||
// Append the search dialog to the body
|
||||
const siteSearch = document.createElement('div');
|
||||
const scrollbarWidth = Math.abs(window.innerWidth - document.documentElement.clientWidth);
|
||||
|
||||
siteSearch.classList.add('search');
|
||||
siteSearch.innerHTML = `
|
||||
<div class="search__overlay"></div>
|
||||
<dialog id="search-dialog" class="search__dialog">
|
||||
<div class="search__content">
|
||||
<div class="search__header">
|
||||
<div id="search-combobox" class="search__input-wrapper">
|
||||
<sl-icon name="search"></sl-icon>
|
||||
<input
|
||||
id="search-input"
|
||||
class="search__input"
|
||||
type="search"
|
||||
placeholder="Search"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
enterkeyhint="go"
|
||||
spellcheck="false"
|
||||
maxlength="100"
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-expanded="true"
|
||||
aria-controls="search-listbox"
|
||||
aria-haspopup="listbox"
|
||||
aria-activedescendant
|
||||
>
|
||||
<button type="button" class="search__clear-button" aria-label="Clear entry" tabindex="-1" hidden>
|
||||
<sl-icon name="x-circle-fill"></sl-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search__body">
|
||||
<ul
|
||||
id="search-listbox"
|
||||
class="search__results"
|
||||
role="listbox"
|
||||
aria-label="Search results"
|
||||
></ul>
|
||||
<div class="search__empty">No matching pages</div>
|
||||
</div>
|
||||
<footer class="search__footer">
|
||||
<small><kbd>↑</kbd> <kbd>↓</kbd> Navigate</small>
|
||||
<small><kbd>↲</kbd> Select</small>
|
||||
<small><kbd>Esc</kbd> Close</small>
|
||||
</footer>
|
||||
</div>
|
||||
</dialog>
|
||||
`;
|
||||
|
||||
const overlay = siteSearch.querySelector('.search__overlay');
|
||||
const dialog = siteSearch.querySelector('.search__dialog');
|
||||
const input = siteSearch.querySelector('.search__input');
|
||||
const clearButton = siteSearch.querySelector('.search__clear-button');
|
||||
const results = siteSearch.querySelector('.search__results');
|
||||
const version = document.documentElement.getAttribute('data-shoelace-version');
|
||||
const key = `search_${version}`;
|
||||
const searchDebounce = 50;
|
||||
const animationDuration = 150;
|
||||
let isShowing = false;
|
||||
let searchTimeout;
|
||||
let searchIndex;
|
||||
let map;
|
||||
|
||||
const loadSearchIndex = new Promise(resolve => {
|
||||
const cache = localStorage.getItem(key);
|
||||
const wait = 'requestIdleCallback' in window ? requestIdleCallback : requestAnimationFrame;
|
||||
|
||||
// Cleanup older search indices (everything before this version)
|
||||
try {
|
||||
const items = { ...localStorage };
|
||||
|
||||
Object.keys(items).forEach(k => {
|
||||
if (key > k) {
|
||||
localStorage.removeItem(k);
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
/* do nothing */
|
||||
}
|
||||
|
||||
// Look for a cached index
|
||||
try {
|
||||
if (cache) {
|
||||
const data = JSON.parse(cache);
|
||||
|
||||
searchIndex = window.lunr.Index.load(data.searchIndex);
|
||||
map = data.map;
|
||||
|
||||
return resolve();
|
||||
}
|
||||
} catch {
|
||||
/* do nothing */
|
||||
}
|
||||
|
||||
// Wait until idle to fetch the index
|
||||
wait(() => {
|
||||
fetch('/assets/search.json')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (!window.lunr) {
|
||||
console.error('The Lunr search client has not yet been loaded.');
|
||||
}
|
||||
|
||||
searchIndex = window.lunr.Index.load(data.searchIndex);
|
||||
map = data.map;
|
||||
|
||||
// Cache the search index for this version
|
||||
if (version) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(data));
|
||||
} catch (err) {
|
||||
console.warn(`Unable to cache the search index: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function show() {
|
||||
isShowing = true;
|
||||
document.body.append(siteSearch);
|
||||
document.body.classList.add('search-visible');
|
||||
document.body.style.setProperty('--docs-search-scroll-lock-size', `${scrollbarWidth}px`);
|
||||
clearButton.hidden = true;
|
||||
requestAnimationFrame(() => input.focus());
|
||||
updateResults();
|
||||
|
||||
dialog.showModal();
|
||||
|
||||
await Promise.all([
|
||||
dialog.animate(
|
||||
[
|
||||
{ opacity: 0, transform: 'scale(.9)', transformOrigin: 'top' },
|
||||
{ opacity: 1, transform: 'scale(1)', transformOrigin: 'top' }
|
||||
],
|
||||
{ duration: animationDuration }
|
||||
).finished,
|
||||
overlay.animate([{ opacity: 0 }, { opacity: 1 }], { duration: animationDuration }).finished
|
||||
]);
|
||||
|
||||
dialog.addEventListener('mousedown', handleMouseDown);
|
||||
dialog.addEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
|
||||
async function hide() {
|
||||
isShowing = false;
|
||||
|
||||
await Promise.all([
|
||||
dialog.animate(
|
||||
[
|
||||
{ opacity: 1, transform: 'scale(1)', transformOrigin: 'top' },
|
||||
{ opacity: 0, transform: 'scale(.9)', transformOrigin: 'top' }
|
||||
],
|
||||
{ duration: animationDuration }
|
||||
).finished,
|
||||
overlay.animate([{ opacity: 1 }, { opacity: 0 }], { duration: animationDuration }).finished
|
||||
]);
|
||||
|
||||
dialog.close();
|
||||
|
||||
input.blur(); // otherwise Safari will scroll to the bottom of the page on close
|
||||
input.value = '';
|
||||
document.body.classList.remove('search-visible');
|
||||
document.body.style.removeProperty('--docs-search-scroll-lock-size');
|
||||
siteSearch.remove();
|
||||
updateResults();
|
||||
|
||||
dialog.removeEventListener('mousedown', handleMouseDown);
|
||||
dialog.removeEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
clearButton.hidden = input.value === '';
|
||||
|
||||
// Debounce search queries
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => updateResults(input.value), searchDebounce);
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
clearButton.hidden = true;
|
||||
input.value = '';
|
||||
input.focus();
|
||||
updateResults();
|
||||
}
|
||||
|
||||
function handleMouseDown(event) {
|
||||
if (!event.target.closest('.search__content')) {
|
||||
hide();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(event) {
|
||||
// Close when pressing escape
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault(); // prevent <dialog> from closing immediately so it can animate
|
||||
event.stopImmediatePropagation();
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle keyboard selections
|
||||
if (['ArrowDown', 'ArrowUp', 'Home', 'End', 'Enter'].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
|
||||
const currentEl = results.querySelector('[data-selected="true"]');
|
||||
const items = [...results.querySelectorAll('li')];
|
||||
const index = items.indexOf(currentEl);
|
||||
let nextEl;
|
||||
|
||||
if (items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowUp':
|
||||
nextEl = items[Math.max(0, index - 1)];
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
nextEl = items[Math.min(items.length - 1, index + 1)];
|
||||
break;
|
||||
case 'Home':
|
||||
nextEl = items[0];
|
||||
break;
|
||||
case 'End':
|
||||
nextEl = items[items.length - 1];
|
||||
break;
|
||||
case 'Enter':
|
||||
currentEl?.querySelector('a')?.click();
|
||||
break;
|
||||
}
|
||||
|
||||
// Update the selected item
|
||||
items.forEach(item => {
|
||||
if (item === nextEl) {
|
||||
input.setAttribute('aria-activedescendant', item.id);
|
||||
item.setAttribute('data-selected', 'true');
|
||||
nextEl.scrollIntoView({ block: 'nearest' });
|
||||
} else {
|
||||
item.setAttribute('data-selected', 'false');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function updateResults(query = '') {
|
||||
try {
|
||||
await loadSearchIndex;
|
||||
|
||||
const hasQuery = query.length > 0;
|
||||
const searchTerms = query
|
||||
.split(' ')
|
||||
.map((term, index, arr) => {
|
||||
// Search API: https://lunrjs.com/guides/searching.html
|
||||
if (index === arr.length - 1) {
|
||||
// The last term is not mandatory and 1x fuzzy. We also duplicate it with a wildcard to match partial words
|
||||
// as the user types.
|
||||
return `${term}~1 ${term}*`;
|
||||
} else {
|
||||
// All other terms are mandatory and 1x fuzzy
|
||||
return `+${term}~1`;
|
||||
}
|
||||
})
|
||||
.join(' ');
|
||||
const matches = hasQuery ? searchIndex.search(searchTerms) : [];
|
||||
const hasResults = hasQuery && matches.length > 0;
|
||||
|
||||
siteSearch.classList.toggle('search--has-results', hasQuery && hasResults);
|
||||
siteSearch.classList.toggle('search--no-results', hasQuery && !hasResults);
|
||||
|
||||
input.setAttribute('aria-activedescendant', '');
|
||||
results.innerHTML = '';
|
||||
|
||||
matches.forEach((match, index) => {
|
||||
const page = map[match.ref];
|
||||
const li = document.createElement('li');
|
||||
const a = document.createElement('a');
|
||||
const displayTitle = page.title ?? '';
|
||||
const displayDescription = page.description ?? '';
|
||||
const displayUrl = page.url.replace(/^\//, '').replace(/\/$/, '');
|
||||
let icon = 'file-text';
|
||||
|
||||
a.setAttribute('role', 'option');
|
||||
a.setAttribute('id', `search-result-item-${match.ref}`);
|
||||
|
||||
if (page.url.includes('getting-started/')) {
|
||||
icon = 'lightbulb';
|
||||
}
|
||||
if (page.url.includes('resources/')) {
|
||||
icon = 'book';
|
||||
}
|
||||
if (page.url.includes('components/')) {
|
||||
icon = 'puzzle';
|
||||
}
|
||||
if (page.url.includes('tokens/')) {
|
||||
icon = 'palette2';
|
||||
}
|
||||
if (page.url.includes('utilities/')) {
|
||||
icon = 'wrench';
|
||||
}
|
||||
if (page.url.includes('tutorials/')) {
|
||||
icon = 'joystick';
|
||||
}
|
||||
|
||||
li.classList.add('search__result');
|
||||
li.setAttribute('role', 'option');
|
||||
li.setAttribute('id', `search-result-item-${match.ref}`);
|
||||
li.setAttribute('data-selected', index === 0 ? 'true' : 'false');
|
||||
|
||||
a.href = page.url;
|
||||
a.innerHTML = `
|
||||
<div class="search__result-icon" aria-hidden="true">
|
||||
<sl-icon name="${icon}"></sl-icon>
|
||||
</div>
|
||||
<div class="search__result__details">
|
||||
<div class="search__result-title"></div>
|
||||
<div class="search__result-description"></div>
|
||||
<div class="search__result-url"></div>
|
||||
</div>
|
||||
`;
|
||||
a.querySelector('.search__result-title').textContent = displayTitle;
|
||||
a.querySelector('.search__result-description').textContent = displayDescription;
|
||||
a.querySelector('.search__result-url').textContent = displayUrl;
|
||||
|
||||
li.appendChild(a);
|
||||
results.appendChild(li);
|
||||
});
|
||||
} catch {
|
||||
// Ignore query errors as the user types
|
||||
}
|
||||
}
|
||||
|
||||
// Show the search dialog when clicking on data-plugin="search"
|
||||
document.addEventListener('click', event => {
|
||||
const searchButton = event.target.closest('[data-plugin="search"]');
|
||||
if (searchButton) {
|
||||
show();
|
||||
}
|
||||
});
|
||||
|
||||
// Show the search dialog when slash (or CMD+K) is pressed and focus is not inside a form element
|
||||
document.addEventListener('keydown', event => {
|
||||
if (
|
||||
!isShowing &&
|
||||
(event.key === '/' || (event.key === 'k' && (event.metaKey || event.ctrlKey))) &&
|
||||
!event.composedPath().some(el => ['input', 'textarea'].includes(el?.tagName?.toLowerCase()))
|
||||
) {
|
||||
event.preventDefault();
|
||||
show();
|
||||
}
|
||||
});
|
||||
|
||||
// Purge cache when we press CMD+CTRL+R
|
||||
document.addEventListener('keydown', event => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key === 'r') {
|
||||
localStorage.clear();
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('input', handleInput);
|
||||
clearButton.addEventListener('click', handleClear);
|
||||
|
||||
// Close when a result is selected
|
||||
results.addEventListener('click', event => {
|
||||
if (event.target.closest('a')) {
|
||||
hide();
|
||||
}
|
||||
});
|
||||
})();
|
29
doc/etemplate2/assets/scripts/turbo.js
Normal file
29
doc/etemplate2/assets/scripts/turbo.js
Normal file
@ -0,0 +1,29 @@
|
||||
import * as Turbo from 'https://cdn.jsdelivr.net/npm/@hotwired/turbo@7.3.0/+esm';
|
||||
|
||||
(() => {
|
||||
if (!window.scrollPositions) {
|
||||
window.scrollPositions = {};
|
||||
}
|
||||
|
||||
function preserveScroll() {
|
||||
document.querySelectorAll('[data-preserve-scroll').forEach(element => {
|
||||
scrollPositions[element.id] = element.scrollTop;
|
||||
});
|
||||
}
|
||||
|
||||
function restoreScroll(event) {
|
||||
document.querySelectorAll('[data-preserve-scroll').forEach(element => {
|
||||
element.scrollTop = scrollPositions[element.id];
|
||||
});
|
||||
|
||||
if (event.detail && event.detail.newBody) {
|
||||
event.detail.newBody.querySelectorAll('[data-preserve-scroll').forEach(element => {
|
||||
element.scrollTop = scrollPositions[element.id];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('turbo:before-cache', preserveScroll);
|
||||
window.addEventListener('turbo:before-render', restoreScroll);
|
||||
window.addEventListener('turbo:render', restoreScroll);
|
||||
})();
|
173
doc/etemplate2/assets/styles/code-previews.css
Normal file
173
doc/etemplate2/assets/styles/code-previews.css
Normal file
@ -0,0 +1,173 @@
|
||||
/* Interactive code blocks */
|
||||
.code-preview {
|
||||
position: relative;
|
||||
border-radius: 3px;
|
||||
background-color: var(--sl-color-neutral-50);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.code-preview__preview {
|
||||
position: relative;
|
||||
border: solid 1px var(--sl-color-neutral-200);
|
||||
border-bottom: none;
|
||||
border-top-left-radius: 3px;
|
||||
border-top-right-radius: 3px;
|
||||
background-color: var(--sl-color-neutral-0);
|
||||
min-width: 20rem;
|
||||
max-width: 100%;
|
||||
padding: 1.5rem 3.25rem 1.5rem 1.5rem;
|
||||
}
|
||||
|
||||
/* Block the preview while dragging to prevent iframes from intercepting drag events */
|
||||
.code-preview__preview--dragging:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.code-preview__resizer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 1.75rem;
|
||||
font-size: 20px;
|
||||
color: var(--sl-color-neutral-600);
|
||||
background-color: var(--sl-color-neutral-0);
|
||||
border-left: solid 1px var(--sl-color-neutral-200);
|
||||
border-top-right-radius: 3px;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
.code-preview__preview {
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
|
||||
.code-preview__resizer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.code-preview__source {
|
||||
border: solid 1px var(--sl-color-neutral-200);
|
||||
border-bottom: none;
|
||||
border-radius: 0 !important;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.code-preview--expanded .code-preview__source {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.code-preview__source pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.code-preview__buttons {
|
||||
position: relative;
|
||||
border: solid 1px var(--sl-color-neutral-200);
|
||||
border-bottom-left-radius: 3px;
|
||||
border-bottom-right-radius: 3px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.code-preview__button {
|
||||
flex: 0 0 auto;
|
||||
height: 2.5rem;
|
||||
min-width: 2.5rem;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: var(--sl-color-neutral-0);
|
||||
font: inherit;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
color: var(--sl-color-neutral-600);
|
||||
padding: 0 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.code-preview__button:not(:last-of-type) {
|
||||
border-right: solid 1px var(--sl-color-neutral-200);
|
||||
}
|
||||
|
||||
.code-preview__button--html,
|
||||
.code-preview__button--react {
|
||||
width: 70px;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.code-preview__button--selected {
|
||||
font-weight: 700;
|
||||
color: var(--sl-color-primary-600);
|
||||
}
|
||||
|
||||
.code-preview__button--codepen {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
width: 6rem;
|
||||
}
|
||||
|
||||
.code-preview__button:first-of-type {
|
||||
border-bottom-left-radius: 3px;
|
||||
}
|
||||
|
||||
.code-preview__button:last-of-type {
|
||||
border-bottom-right-radius: 3px;
|
||||
}
|
||||
|
||||
.code-preview__button:hover,
|
||||
.code-preview__button:active {
|
||||
box-shadow: 0 0 0 1px var(--sl-color-primary-400);
|
||||
border-right-color: transparent;
|
||||
background-color: var(--sl-color-primary-50);
|
||||
color: var(--sl-color-primary-600);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.code-preview__button:focus-visible {
|
||||
outline: none;
|
||||
outline: var(--sl-focus-ring);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.code-preview__toggle {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
color: var(--sl-color-neutral-600);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.code-preview__toggle svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.code-preview--expanded .code-preview__toggle svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* We can apply data-flavor="html|react" to any element on the page to toggle it when the flavor changes */
|
||||
.flavor-html [data-flavor]:not([data-flavor='html']) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.flavor-react [data-flavor]:not([data-flavor='react']) {
|
||||
display: none;
|
||||
}
|
1422
doc/etemplate2/assets/styles/docs.css
Normal file
1422
doc/etemplate2/assets/styles/docs.css
Normal file
File diff suppressed because it is too large
Load Diff
347
doc/etemplate2/assets/styles/search.css
Normal file
347
doc/etemplate2/assets/styles/search.css
Normal file
@ -0,0 +1,347 @@
|
||||
/* Search plugin */
|
||||
:root,
|
||||
:root.sl-theme-dark {
|
||||
--docs-search-box-background: var(--sl-color-neutral-0);
|
||||
--docs-search-box-border-width: 1px;
|
||||
--docs-search-box-border-color: var(--sl-color-neutral-300);
|
||||
--docs-search-box-color: var(--sl-color-neutral-600);
|
||||
--docs-search-dialog-background: var(--sl-color-neutral-0);
|
||||
--docs-search-border-width: var(--docs-border-width);
|
||||
--docs-search-border-color: var(--docs-border-color);
|
||||
--docs-search-text-color: var(--sl-color-neutral-900);
|
||||
--docs-search-text-color-muted: var(--sl-color-neutral-500);
|
||||
--docs-search-font-weight-normal: var(--sl-font-weight-normal);
|
||||
--docs-search-font-weight-semibold: var(--sl-font-weight-semibold);
|
||||
--docs-search-border-radius: calc(2 * var(--docs-border-radius));
|
||||
--docs-search-accent-color: var(--sl-color-primary-600);
|
||||
--docs-search-icon-color: var(--sl-color-neutral-500);
|
||||
--docs-search-icon-color-active: var(--sl-color-neutral-600);
|
||||
--docs-search-shadow: var(--docs-shadow-x-large);
|
||||
--docs-search-result-background-hover: var(--sl-color-neutral-100);
|
||||
--docs-search-result-color-hover: var(--sl-color-neutral-900);
|
||||
--docs-search-result-background-active: var(--sl-color-primary-600);
|
||||
--docs-search-result-color-active: var(--sl-color-neutral-0);
|
||||
--docs-search-focus-ring: var(--sl-focus-ring);
|
||||
--docs-search-overlay-background: rgb(0 0 0 / 0.33);
|
||||
}
|
||||
|
||||
:root.sl-theme-dark {
|
||||
--docs-search-overlay-background: rgb(71 71 71 / 0.33);
|
||||
}
|
||||
|
||||
body.search-visible {
|
||||
padding-right: var(--docs-search-scroll-lock-size) !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
/* Search box */
|
||||
.search-box {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
background: var(--docs-search-box-background);
|
||||
border: solid var(--docs-search-box-border-width) var(--docs-search-box-border-color);
|
||||
font: inherit;
|
||||
color: var(--docs-search-box-color);
|
||||
padding: 0.75rem 1rem;
|
||||
margin: var(--sl-spacing-large) 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-box span {
|
||||
flex: 1 1 auto;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
text-align: left;
|
||||
line-height: 1;
|
||||
margin: 0 0.75rem;
|
||||
}
|
||||
|
||||
.search-box:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search-box:focus-visible {
|
||||
outline: var(--docs-search-focus-ring);
|
||||
}
|
||||
|
||||
/* Site search */
|
||||
.search {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.search[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.search__overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: var(--docs-search-overlay-background);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.search__dialog {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.search__dialog:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search__dialog::backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Fixes an iOS Safari 16.4 bug that draws the parent element's border radius incorrectly when showing/hiding results */
|
||||
.search__header {
|
||||
background-color: var(--docs-search-dialog-background);
|
||||
border-radius: var(--docs-search-border-radius);
|
||||
}
|
||||
|
||||
.search--has-results .search__header {
|
||||
border-top-left-radius: var(--docs-search-border-radius);
|
||||
border-top-right-radius: var(--docs-search-border-radius);
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.search__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
max-height: calc(100vh - 20rem);
|
||||
background-color: var(--docs-search-dialog-background);
|
||||
border-radius: var(--docs-search-border-radius);
|
||||
box-shadow: var(--docs-search-shadow);
|
||||
padding: 0;
|
||||
margin: 10rem auto;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 900px) {
|
||||
.search__content {
|
||||
max-width: calc(100% - 2rem);
|
||||
max-height: calc(90svh);
|
||||
margin: 4vh 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.search__input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search__input-wrapper sl-icon {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
flex: 0 0 auto;
|
||||
color: var(--docs-search-icon-color);
|
||||
margin: 0 1.5rem;
|
||||
}
|
||||
|
||||
.search__clear-button {
|
||||
display: flex;
|
||||
background: none;
|
||||
border: none;
|
||||
font: inherit;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search__clear-button[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.search__clear-button:active sl-icon {
|
||||
color: var(--docs-search-icon-color-active);
|
||||
}
|
||||
|
||||
.search__input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
font: inherit;
|
||||
font-size: 1.5rem;
|
||||
font-weight: var(--docs-search-font-weight-normal);
|
||||
color: var(--docs-search-text-color);
|
||||
background: transparent;
|
||||
padding: 1rem 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.search__input::placeholder {
|
||||
color: var(--docs-search-text-color-muted);
|
||||
}
|
||||
|
||||
.search__input::-webkit-search-decoration,
|
||||
.search__input::-webkit-search-cancel-button,
|
||||
.search__input::-webkit-search-results-button,
|
||||
.search__input::-webkit-search-results-decoration {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.search__input:focus,
|
||||
.search__input:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search__body {
|
||||
flex: 1 1 auto;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.search--has-results .search__body {
|
||||
border-top: solid var(--docs-search-border-width) var(--docs-search-border-color);
|
||||
}
|
||||
|
||||
.search__results {
|
||||
display: none;
|
||||
line-height: 1.2;
|
||||
list-style: none;
|
||||
padding: 0.5rem 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.search--has-results .search__results {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.search__results a {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
padding: 0.5rem 1.5rem;
|
||||
}
|
||||
|
||||
.search__results a:focus-visible {
|
||||
outline: var(--docs-search-focus-ring);
|
||||
}
|
||||
|
||||
.search__results li a:hover,
|
||||
.search__results li a:hover small {
|
||||
background-color: var(--docs-search-result-background-hover);
|
||||
color: var(--docs-search-result-color-hover);
|
||||
}
|
||||
|
||||
.search__results li[data-selected='true'] a,
|
||||
.search__results li[data-selected='true'] a * {
|
||||
outline: none;
|
||||
background-color: var(--docs-search-result-background-active);
|
||||
color: var(--docs-search-result-color-active);
|
||||
}
|
||||
|
||||
.search__results h3 {
|
||||
font-weight: var(--docs-search-font-weight-semibold);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.search__results small {
|
||||
display: block;
|
||||
color: var(--docs-search-text-color-muted);
|
||||
}
|
||||
|
||||
.search__result {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.search__result a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.search__result-icon {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
color: var(--docs-search-text-color-muted);
|
||||
}
|
||||
|
||||
.search__result-icon sl-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.search__result__details {
|
||||
width: calc(100% - 3rem);
|
||||
}
|
||||
|
||||
.search__result-title,
|
||||
.search__result-description,
|
||||
.search__result-url {
|
||||
max-width: 400px;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.search__result-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: var(--docs-search-font-weight-semibold);
|
||||
color: var(--docs-search-accent-color);
|
||||
}
|
||||
|
||||
.search__result-description {
|
||||
font-size: 0.875rem;
|
||||
color: var(--docs-search-text-color);
|
||||
}
|
||||
|
||||
.search__result-url {
|
||||
font-size: 0.875rem;
|
||||
color: var(--docs-search-text-color-muted);
|
||||
}
|
||||
|
||||
.search__empty {
|
||||
display: none;
|
||||
border-top: solid var(--docs-search-border-width) var(--docs-search-border-color);
|
||||
text-align: center;
|
||||
color: var(--docs-search-text-color-muted);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.search--no-results .search__empty {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.search__footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
border-top: solid var(--docs-search-border-width) var(--docs-search-border-color);
|
||||
border-bottom-left-radius: inherit;
|
||||
border-bottom-right-radius: inherit;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.search__footer small {
|
||||
color: var(--docs-search-text-color-muted);
|
||||
}
|
||||
|
||||
.search__footer small kbd:last-of-type {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 900px) {
|
||||
.search__footer {
|
||||
display: none;
|
||||
}
|
||||
}
|
198
doc/etemplate2/custom-elements-manifest.config.mjs
Normal file
198
doc/etemplate2/custom-elements-manifest.config.mjs
Normal file
@ -0,0 +1,198 @@
|
||||
import * as path from 'path';
|
||||
//import {customElementJetBrainsPlugin} from 'custom-element-jet-brains-integration';
|
||||
//import {customElementVsCodePlugin} from 'custom-element-vs-code-integration';
|
||||
import {parse} from 'comment-parser';
|
||||
import {pascalCase} from 'pascal-case';
|
||||
import commandLineArgs from 'command-line-args';
|
||||
import fs from 'fs';
|
||||
|
||||
const packageData = JSON.parse(fs.readFileSync('package.json', 'utf8'));
|
||||
const {name, description, version, author, homepage, license} = packageData;
|
||||
|
||||
|
||||
function noDash(string)
|
||||
{
|
||||
return string.replace(/^\s?-/, '').trim();
|
||||
}
|
||||
|
||||
function replace(string, terms)
|
||||
{
|
||||
terms.forEach(({from, to}) =>
|
||||
{
|
||||
string = string?.replace(from, to);
|
||||
});
|
||||
|
||||
return string;
|
||||
}
|
||||
|
||||
export default {
|
||||
globs: ["api/js/etemplate/**/Et2[!DFILST]*/*.ts"], // There's something wrong with some widgets, they break the parser
|
||||
/** Globs to exclude */
|
||||
exclude: ["*Date*"],//, 'et2_*.ts', '**/test/*', '**/*.styles.ts', '**/*.test.ts'],
|
||||
dev: true,
|
||||
litelement: true,
|
||||
plugins: [
|
||||
// Append package data
|
||||
{
|
||||
name: 'egroupware-package-data',
|
||||
packageLinkPhase({customElementsManifest})
|
||||
{
|
||||
customElementsManifest.package = {name, description, version, author, homepage, license};
|
||||
}
|
||||
},
|
||||
|
||||
// Parse custom jsDoc tags
|
||||
{
|
||||
name: 'shoelace-custom-tags',
|
||||
analyzePhase({ts, node, moduleDoc})
|
||||
{
|
||||
switch (node.kind)
|
||||
{
|
||||
case ts.SyntaxKind.ClassDeclaration:
|
||||
{
|
||||
const className = node.name.getText();
|
||||
const classDoc = moduleDoc?.declarations?.find(declaration => declaration.name === className);
|
||||
const customTags = ['animation', 'dependency', 'documentation', 'since', 'status', 'title'];
|
||||
let customComments = '/**';
|
||||
|
||||
node.jsDoc?.forEach(jsDoc =>
|
||||
{
|
||||
jsDoc?.tags?.forEach(tag =>
|
||||
{
|
||||
const tagName = tag.tagName.getText();
|
||||
|
||||
if (customTags.includes(tagName))
|
||||
{
|
||||
customComments += `\n * @${tagName} ${tag.comment}`;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// This is what allows us to map JSDOC comments to ReactWrappers.
|
||||
classDoc['jsDoc'] = node.jsDoc?.map(jsDoc => jsDoc.getFullText()).join('\n');
|
||||
|
||||
// const parsed = parse(`${customComments}\n */`);
|
||||
/*
|
||||
parsed[0].tags?.forEach(t =>
|
||||
{
|
||||
switch (t.tag)
|
||||
{
|
||||
// Animations
|
||||
case 'animation':
|
||||
if (!Array.isArray(classDoc['animations']))
|
||||
{
|
||||
classDoc['animations'] = [];
|
||||
}
|
||||
classDoc['animations'].push({
|
||||
name: t.name,
|
||||
description: noDash(t.description)
|
||||
});
|
||||
break;
|
||||
|
||||
// Dependencies
|
||||
case 'dependency':
|
||||
if (!Array.isArray(classDoc['dependencies']))
|
||||
{
|
||||
classDoc['dependencies'] = [];
|
||||
}
|
||||
classDoc['dependencies'].push(t.name);
|
||||
break;
|
||||
|
||||
// Value-only metadata tags
|
||||
case 'documentation':
|
||||
case 'since':
|
||||
case 'status':
|
||||
case 'title':
|
||||
classDoc[t.tag] = t.name;
|
||||
break;
|
||||
|
||||
// All other tags
|
||||
default:
|
||||
if (!Array.isArray(classDoc[t.tag]))
|
||||
{
|
||||
classDoc[t.tag] = [];
|
||||
}
|
||||
|
||||
classDoc[t.tag].push({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
type: t.type || undefined
|
||||
});
|
||||
}
|
||||
});
|
||||
*/
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'shoelace-translate-module-paths',
|
||||
packageLinkPhase({customElementsManifest})
|
||||
{
|
||||
customElementsManifest?.modules?.forEach(mod =>
|
||||
{
|
||||
//
|
||||
// CEM paths look like this:
|
||||
//
|
||||
// src/components/button/button.ts
|
||||
//
|
||||
// But we want them to look like this:
|
||||
//
|
||||
// components/button/button.js
|
||||
//
|
||||
const terms = [
|
||||
{from: /^src\//, to: ''}, // Strip the src/ prefix
|
||||
{from: /\.component.(t|j)sx?$/, to: '.js'} // Convert .ts to .js
|
||||
];
|
||||
|
||||
mod.path = replace(mod.path, terms);
|
||||
|
||||
for (const ex of mod.exports ?? [])
|
||||
{
|
||||
ex.declaration.module = replace(ex.declaration.module, terms);
|
||||
}
|
||||
|
||||
for (const dec of mod.declarations ?? [])
|
||||
{
|
||||
if (dec.kind === 'class')
|
||||
{
|
||||
for (const member of dec.members ?? [])
|
||||
{
|
||||
if (member.inheritedFrom)
|
||||
{
|
||||
member.inheritedFrom.module = replace(member.inheritedFrom.module, terms);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// Generate custom VS Code data
|
||||
/*
|
||||
customElementVsCodePlugin({
|
||||
outdir,
|
||||
cssFileName: null,
|
||||
referencesTemplate: (_, tag) => [
|
||||
{
|
||||
name: 'Documentation',
|
||||
url: `https://shoelace.style/components/${tag.replace('sl-', '')}`
|
||||
}
|
||||
]
|
||||
}),
|
||||
|
||||
customElementJetBrainsPlugin({
|
||||
excludeCss: true,
|
||||
referencesTemplate: (_, tag) =>
|
||||
{
|
||||
return {
|
||||
name: 'Documentation',
|
||||
url: `https://shoelace.style/components/${tag.replace('sl-', '')}`
|
||||
};
|
||||
}
|
||||
})
|
||||
|
||||
*/
|
||||
]
|
||||
};
|
283
doc/etemplate2/eleventy.config.cjs
Normal file
283
doc/etemplate2/eleventy.config.cjs
Normal file
@ -0,0 +1,283 @@
|
||||
/* eslint-disable no-invalid-this */
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const lunr = require('lunr');
|
||||
const {capitalCase} = require('change-case');
|
||||
const {JSDOM} = require('jsdom');
|
||||
const {customElementsManifest, getAllComponents} = require('./_utilities/cem.cjs');
|
||||
const egwFlavoredMarkdown = require('./_utilities/markdown.cjs');
|
||||
const activeLinks = require('./_utilities/active-links.cjs');
|
||||
const anchorHeadings = require('./_utilities/anchor-headings.cjs');
|
||||
const codePreviews = require('./_utilities/code-previews.cjs');
|
||||
const copyCodeButtons = require('./_utilities/copy-code-buttons.cjs');
|
||||
const externalLinks = require('./_utilities/external-links.cjs');
|
||||
const highlightCodeBlocks = require('./_utilities/highlight-code.cjs');
|
||||
const tableOfContents = require('./_utilities/table-of-contents.cjs');
|
||||
const prettier = require('./_utilities/prettier.cjs');
|
||||
const scrollingTables = require('./_utilities/scrolling-tables.cjs');
|
||||
const typography = require('./_utilities/typography.cjs');
|
||||
const replacer = require('./_utilities/replacer.cjs');
|
||||
|
||||
const assetsDir = 'assets';
|
||||
const cdndir = 'cdn';
|
||||
const npmdir = 'dist';
|
||||
const allComponents = getAllComponents();
|
||||
let hasBuiltSearchIndex = false;
|
||||
|
||||
// Write component data to file, 11ty will pick it up and create pages - the name & location are important
|
||||
fs.writeFileSync("_data/components.json", JSON.stringify(allComponents));
|
||||
|
||||
// Put it here too, since addPassthroughCopy() ignores it
|
||||
fs.copyFileSync("../dist/custom-elements.json", "assets/custom-elements.json");
|
||||
|
||||
module.exports = function (eleventyConfig)
|
||||
{
|
||||
//
|
||||
// Global data
|
||||
//
|
||||
eleventyConfig.addGlobalData('baseUrl', 'https://egroupware.org/'); // the production URL
|
||||
eleventyConfig.addGlobalData('layout', 'default'); // make 'default' the default layout
|
||||
eleventyConfig.addGlobalData('toc', true); // enable the table of contents
|
||||
eleventyConfig.addGlobalData('meta', {
|
||||
title: 'EGroupware',
|
||||
description: '',
|
||||
image: 'images/logo.svg',
|
||||
version: customElementsManifest.package.version,
|
||||
components: allComponents,
|
||||
cdndir,
|
||||
npmdir
|
||||
});
|
||||
|
||||
//
|
||||
// Layout aliases
|
||||
//
|
||||
eleventyConfig.addLayoutAlias('default', 'default.njk');
|
||||
|
||||
//
|
||||
// Copy EGw stuff in
|
||||
//
|
||||
eleventyConfig.addPassthroughCopy({"../../api/templates/default/images/logo.svg": "assets/images/logo.svg"});
|
||||
eleventyConfig.addPassthroughCopy({"../../pixelegg/css/monochrome.css": "assets/styles/monochrome.css"});
|
||||
//eleventyConfig.addPassthroughCopy({"../../api/js": "assets/scripts/chunks"});
|
||||
eleventyConfig.addPassthroughCopy({"../../api/js/etemplate/etemplate2.js": "assets/scripts/etemplate/etemplate2.js"});
|
||||
|
||||
// Shoelace
|
||||
eleventyConfig.addPassthroughCopy({"../../node_modules/@shoelace-style/shoelace/dist/": "assets/shoelace/"});
|
||||
/*
|
||||
eleventyConfig.addPassthroughCopy({"../../node_modules/@shoelace-style/shoelace/dist/shoelace-autoloader.js": "assets/shoelace/shoelace-autoloader.js"});
|
||||
eleventyConfig.addPassthroughCopy({"../../node_modules/@shoelace-style/shoelace/dist/themes/*": "assets/shoelace/themes/"});
|
||||
eleventyConfig.addPassthroughCopy({"../../node_modules/@shoelace-style/shoelace/dist/chunks/*": "assets/shoelace/chunks/"});
|
||||
eleventyConfig.addPassthroughCopy({"../../node_modules/@shoelace-style/shoelace/dist/assets/*": "assets/shoelace/assets/"});
|
||||
eleventyConfig.addPassthroughCopy({"../../node_modules/@shoelace-style/shoelace/dist/components/*": "assets/shoelace/components/"});
|
||||
*/
|
||||
|
||||
//
|
||||
// Copy assets
|
||||
//
|
||||
eleventyConfig.addPassthroughCopy(assetsDir);
|
||||
|
||||
eleventyConfig.setServerPassthroughCopyBehavior('passthrough'); // emulates passthrough copy during --serve
|
||||
|
||||
//
|
||||
// Functions
|
||||
//
|
||||
|
||||
// Generates a URL relative to the site's root
|
||||
eleventyConfig.addNunjucksGlobal('rootUrl', (value = '', absolute = false) =>
|
||||
{
|
||||
value = path.join('/', value);
|
||||
return absolute ? new URL(value, eleventyConfig.globalData.baseUrl).toString() : value;
|
||||
});
|
||||
|
||||
// Generates a URL relative to the site's asset directory
|
||||
eleventyConfig.addNunjucksGlobal('assetUrl', (value = '', absolute = false) =>
|
||||
{
|
||||
value = path.join(`/${assetsDir}`, value);
|
||||
return absolute ? new URL(value, eleventyConfig.globalData.baseUrl).toString() : value;
|
||||
});
|
||||
|
||||
// Fetches a specific component's metadata
|
||||
eleventyConfig.addNunjucksGlobal('getComponent', tagName =>
|
||||
{
|
||||
const component = allComponents.find(c => c.tagName === tagName);
|
||||
if (!component)
|
||||
{
|
||||
throw new Error(
|
||||
`Unable to find a component called "${tagName}". Make sure the file name is the same as the component's tag ` +
|
||||
`name (minus the sl- prefix).`
|
||||
);
|
||||
}
|
||||
return component;
|
||||
});
|
||||
|
||||
//
|
||||
// Custom markdown syntaxes
|
||||
//
|
||||
eleventyConfig.setLibrary('md', egwFlavoredMarkdown);
|
||||
|
||||
//
|
||||
// Filters
|
||||
//
|
||||
eleventyConfig.addFilter('markdown', content =>
|
||||
{
|
||||
return egwFlavoredMarkdown.render(content);
|
||||
});
|
||||
|
||||
eleventyConfig.addFilter('markdownInline', content =>
|
||||
{
|
||||
return egwFlavoredMarkdown.renderInline(content);
|
||||
});
|
||||
|
||||
eleventyConfig.addFilter('classNameToComponentName', className =>
|
||||
{
|
||||
let name = capitalCase(className.replace(/^Sl/, ''));
|
||||
if (name === 'Qr Code')
|
||||
{
|
||||
name = 'QR Code';
|
||||
} // manual override
|
||||
return name;
|
||||
});
|
||||
|
||||
eleventyConfig.addFilter('removeSlPrefix', tagName =>
|
||||
{
|
||||
return tagName.replace(/^sl-/, '');
|
||||
});
|
||||
|
||||
//
|
||||
// Transforms
|
||||
//
|
||||
eleventyConfig.addTransform('html-transform', function (content)
|
||||
{
|
||||
// Parse the template and get a Document object
|
||||
const doc = new JSDOM(content, {
|
||||
// We must set a default URL so links are parsed with a hostname. Let's use a bogus TLD so we can easily
|
||||
// identify which ones are internal and which ones are external.
|
||||
url: `https://internal/`
|
||||
}).window.document;
|
||||
|
||||
// DOM transforms
|
||||
activeLinks(doc, {pathname: this.page.url});
|
||||
anchorHeadings(doc, {
|
||||
within: '#content .content__body',
|
||||
levels: ['h2', 'h3', 'h4', 'h5']
|
||||
});
|
||||
tableOfContents(doc, {
|
||||
levels: ['h2', 'h3'],
|
||||
container: '#content .content__toc > ul',
|
||||
within: '#content .content__body'
|
||||
});
|
||||
codePreviews(doc);
|
||||
externalLinks(doc, {target: '_blank'});
|
||||
highlightCodeBlocks(doc);
|
||||
scrollingTables(doc);
|
||||
copyCodeButtons(doc); // must be after codePreviews + highlightCodeBlocks
|
||||
typography(doc, '#content');
|
||||
replacer(doc, [
|
||||
{pattern: '%VERSION%', replacement: customElementsManifest.package.version},
|
||||
{pattern: '%CDNDIR%', replacement: cdndir},
|
||||
{pattern: '%NPMDIR%', replacement: npmdir}
|
||||
]);
|
||||
|
||||
// Serialize the Document object to an HTML string and prepend the doctype
|
||||
content = `<!DOCTYPE html>\n${doc.documentElement.outerHTML}`;
|
||||
|
||||
// String transforms
|
||||
content = prettier(content);
|
||||
|
||||
return content;
|
||||
});
|
||||
|
||||
//
|
||||
// Build a search index
|
||||
//
|
||||
eleventyConfig.on('eleventy.after', ({results}) =>
|
||||
{
|
||||
// We only want to build the search index on the first run so all pages get indexed.
|
||||
if (hasBuiltSearchIndex)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const map = {};
|
||||
const searchIndexFilename = path.join(eleventyConfig.dir.output, assetsDir, 'search.json');
|
||||
const lunrInput = path.resolve('../../node_modules/lunr/lunr.min.js');
|
||||
const lunrOutput = path.join(eleventyConfig.dir.output, assetsDir, 'scripts/lunr.js');
|
||||
const searchIndex = lunr(function ()
|
||||
{
|
||||
// The search index uses these field names extensively, so shortening them can save some serious bytes. The
|
||||
// initial index file went from 468 KB => 401 KB by using single-character names!
|
||||
this.ref('id'); // id
|
||||
this.field('t', {boost: 50}); // title
|
||||
this.field('h', {boost: 25}); // headings
|
||||
this.field('c'); // content
|
||||
|
||||
results.forEach((result, index) =>
|
||||
{
|
||||
const url = path
|
||||
.join('/', path.relative(eleventyConfig.dir.output, result.outputPath))
|
||||
.replace(/\\/g, '/') // convert backslashes to forward slashes
|
||||
.replace(/\/index.html$/, '/'); // convert trailing /index.html to /
|
||||
const doc = new JSDOM(result.content, {
|
||||
// We must set a default URL so links are parsed with a hostname. Let's use a bogus TLD so we can easily
|
||||
// identify which ones are internal and which ones are external.
|
||||
url: `https://internal/`
|
||||
}).window.document;
|
||||
const content = doc.querySelector('#content');
|
||||
|
||||
// Get title and headings
|
||||
const title = (doc.querySelector('title')?.textContent || path.basename(result.outputPath)).trim();
|
||||
const headings = [...content.querySelectorAll('h1, h2, h3, h4')]
|
||||
.map(heading => heading.textContent)
|
||||
.join(' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
// Remove code blocks and whitespace from content
|
||||
[...content.querySelectorAll('code[class|=language]')].forEach(code => code.remove());
|
||||
const textContent = content.textContent.replace(/\s+/g, ' ').trim();
|
||||
|
||||
// Update the index and map
|
||||
this.add({id: index, t: title, h: headings, c: textContent});
|
||||
map[index] = {title, url};
|
||||
});
|
||||
});
|
||||
|
||||
// Copy the Lunr search client and write the index
|
||||
fs.mkdirSync(path.dirname(lunrOutput), {recursive: true});
|
||||
fs.copyFileSync(lunrInput, lunrOutput);
|
||||
fs.writeFileSync(searchIndexFilename, JSON.stringify({searchIndex, map}), 'utf-8');
|
||||
|
||||
hasBuiltSearchIndex = true;
|
||||
});
|
||||
|
||||
//
|
||||
// Send a signal to stdout that let's the build know we've reached this point
|
||||
//
|
||||
eleventyConfig.on('eleventy.after', () =>
|
||||
{
|
||||
console.log('[eleventy.after]');
|
||||
});
|
||||
|
||||
//
|
||||
// Dev server options (see https://www.11ty.dev/docs/dev-server/#options)
|
||||
//
|
||||
eleventyConfig.setServerOptions({
|
||||
domDiff: false, // disable dom diffing so custom elements don't break on reload,
|
||||
port: 4000, // if port 4000 is taken, 11ty will use the next one available
|
||||
watch: ['cdn/**/*'] // additional files to watch that will trigger server updates (array of paths or globs)
|
||||
});
|
||||
|
||||
//
|
||||
// 11ty config
|
||||
//
|
||||
return {
|
||||
dir: {
|
||||
input: 'pages',
|
||||
output: '../dist/site',
|
||||
includes: '../_includes', // resolved relative to the input dir
|
||||
data: '../_data'
|
||||
},
|
||||
markdownTemplateEngine: 'njk', // use Nunjucks instead of Liquid for markdown files
|
||||
templateEngineOverride: ['njk'] // just Nunjucks and then markdown
|
||||
};
|
||||
};
|
8
doc/etemplate2/pages/components/default_component.njk
Normal file
8
doc/etemplate2/pages/components/default_component.njk
Normal file
@ -0,0 +1,8 @@
|
||||
---
|
||||
pagination:
|
||||
data: components
|
||||
size: 1
|
||||
alias: component
|
||||
permalink: "components/{{component.tagName | slugify}}/"
|
||||
layout: component.njk
|
||||
---
|
6
doc/etemplate2/pages/components/sandbox.md
Normal file
6
doc/etemplate2/pages/components/sandbox.md
Normal file
@ -0,0 +1,6 @@
|
||||
## Widget Sandbox
|
||||
|
||||
You can see and play with the widgets, once they're loaded.
|
||||
Maybe this should go on each page, limited to just that widget.
|
||||
|
||||
<api-viewer src="/assets/custom-elements.json"></api-viewer>
|
26
doc/etemplate2/pages/getting-started/styling.md
Normal file
26
doc/etemplate2/pages/getting-started/styling.md
Normal file
@ -0,0 +1,26 @@
|
||||
## Styling
|
||||
|
||||
Our overall styling is a combination of our site-wide style (pixelegg), etemplate2.css
|
||||
and [Shoelace](https://shoelace.style/) styles
|
||||
|
||||
Some handy excerpts:
|
||||
|
||||
### Global CSS variables
|
||||
|
||||
```css
|
||||
:root {
|
||||
--primary-background-color: #4177a2;
|
||||
--highlight-background-color: rgba(153, 204, 255, .4);
|
||||
|
||||
--label-color: #000000;
|
||||
/* For fixed width labels - use class 'et2-label-fixed'*/
|
||||
--label-width: 8em;
|
||||
|
||||
--input-border-color: #E6E6E6;
|
||||
--input-text-color: #26537C;
|
||||
|
||||
--warning-color: rgba(255, 204, 0, .5);
|
||||
--error-color: rgba(204, 0, 51, .5);
|
||||
|
||||
}
|
||||
```
|
24
doc/etemplate2/pages/getting-started/widgets.md
Normal file
24
doc/etemplate2/pages/getting-started/widgets.md
Normal file
@ -0,0 +1,24 @@
|
||||
## Widgets
|
||||
|
||||
Widgets are the building blocks of our UI.
|
||||
We are currently making all our
|
||||
widgets [WebComponents](https://developer.mozilla.org/en-US/docs/Web/API/Web_components)
|
||||
based on [Lit](https://lit.dev/docs/). Many of our widgets use [Shoelace](https://shoelace.style) components as building
|
||||
blocks.
|
||||
|
||||
If you just want to use existing widgets, you can put them in your .xet template file:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE overlay PUBLIC "-//EGroupware GmbH//eTemplate 2.0//EN" "https://www.egroupware.org/etemplate2.0.dtd">
|
||||
<overlay>
|
||||
<template id="myapp.mytemplate">
|
||||
<et2-vbox>
|
||||
<et2-textbox id="name" label="Your name" class="et2-label-fixed"></et2-textbox>
|
||||
<et2-select-number id="quantity" label="Quantity" class="et2-label-fixed"></et2-select-number>
|
||||
</et2-vbox>
|
||||
</template>
|
||||
</overlay>
|
||||
```
|
||||
|
||||
<img src="/assets/images/widgets_rendered_example.png" alt="Rendered example template">
|
18
doc/etemplate2/pages/index.md
Normal file
18
doc/etemplate2/pages/index.md
Normal file
@ -0,0 +1,18 @@
|
||||
---
|
||||
meta:
|
||||
title: 'EGroupware'
|
||||
description: Web based groupware server
|
||||
toc: false
|
||||
---
|
||||
|
||||
EGroupware Etemplate development docs
|
||||
|
||||
## Quick Start
|
||||
|
||||
EGroupware UI is created from templates stored in <app>/templates/default/*.xet files.
|
||||
The templates are composed of widget tags.
|
||||
If this works, here is a box:
|
||||
|
||||
```html:preview
|
||||
<et2-box>Box content</et2-box>
|
||||
```
|
69
doc/etemplate2/pages/tutorials/automatic-testing.md
Normal file
69
doc/etemplate2/pages/tutorials/automatic-testing.md
Normal file
@ -0,0 +1,69 @@
|
||||
## Automatic testing
|
||||
|
||||
Automatic tests go in the `test/` subfolder of your component's directory. They will be found and run by
|
||||
“web-test-runner”.
|
||||
Tests are written using
|
||||
|
||||
* Mocha (https://mochajs.org/) & Chai Assertion Library (https://www.chaijs.com/api/assert/)
|
||||
* Playwright (https://playwright.dev/docs/intro) runs the tests in actual browsers.
|
||||
|
||||
Here's a simple example:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Test file for Etemplate webComponent Textbox
|
||||
*/
|
||||
import {assert, fixture, html} from '@open-wc/testing';
|
||||
import {Et2Textbox} from "../Et2Textbox";
|
||||
import {inputBasicTests} from "../../Et2InputWidget/test/InputBasicTests";
|
||||
|
||||
// Reference to component under test
|
||||
let element : Et2Textbox;
|
||||
|
||||
async function before()
|
||||
{
|
||||
// Create an element to test with, and wait until it's ready
|
||||
element = await fixture<Et2Textbox>(html`
|
||||
<et2-textbox></et2-textbox>
|
||||
`);
|
||||
return element;
|
||||
}
|
||||
|
||||
describe("Textbox widget", () =>
|
||||
{
|
||||
// Setup run before each test
|
||||
beforeEach(before);
|
||||
|
||||
it('is defined', () =>
|
||||
{
|
||||
assert.instanceOf(element, Et2Textbox);
|
||||
});
|
||||
|
||||
it('has a label', () =>
|
||||
{
|
||||
element.set_label("Yay label");
|
||||
assert.isEmpty(element.shadowRoot.querySelectorAll('.et2_label'));
|
||||
})
|
||||
});
|
||||
|
||||
// Run some common, basic tests for inputs (readonly, value, etc.)
|
||||
inputBasicTests(before, "I'm a good test value", "input");
|
||||
```
|
||||
|
||||
This verifies that the component can be loaded and created. `inputBasicTests()` checks readonly and in/out values.
|
||||
|
||||
### What to test
|
||||
|
||||
#### Can the component be loaded and created?
|
||||
|
||||
Quite often components get accidental dependencies that complicate things, but sometimes they just break.
|
||||
|
||||
#### Value in = value out
|
||||
|
||||
Many of our components do correction and coercion on bad data or invalid values, but you should test that values out
|
||||
match
|
||||
the values going in. How to do this, and what to do with bad values, depends on the component.
|
||||
|
||||
### Test tips
|
||||
|
||||
* Always use `this.egw()`. It can be easily stubbed for your test. Global `egw` cannot.
|
39
doc/etemplate2/pages/tutorials/creating-a-widget.md
Normal file
39
doc/etemplate2/pages/tutorials/creating-a-widget.md
Normal file
@ -0,0 +1,39 @@
|
||||
## Creating a component
|
||||
|
||||
ETemplate components are [LitElements](https://lit.dev/docs/) that are wrapped with
|
||||
our [Et2Widget](https://github.com/EGroupware/egroupware/blob/master/api/js/etemplate/Et2Widget/Et2Widget.ts) mixin,
|
||||
which adds properties and methods to support loading from our template files and returning values to the server. They
|
||||
should (relatively) stand-alone.
|
||||
|
||||
Common components are in `api/js/etemplate/`. You can add application specific components in `<appname>/js/`.
|
||||
|
||||
### Create the files
|
||||
|
||||
```
|
||||
myapp/
|
||||
js/
|
||||
MyWidget/
|
||||
test/
|
||||
MyWidget.ts
|
||||
|
||||
```
|
||||
|
||||
You should have [automatic tests](/tutorials/automatic-testing) to verify your component and avoid regressions
|
||||
in `test/`.
|
||||
|
||||
### Get it loaded
|
||||
|
||||
To have EGroupware load your component, it must be included somewhere.
|
||||
Add your component to the `include` block at the top of `/api/js/etemplate/etemplate2.js`. If you have an application
|
||||
specific component, include at the top of your `app.js`.
|
||||
|
||||
```typescript
|
||||
...
|
||||
import './MyWidget/MyWidget.ts';
|
||||
|
||||
...
|
||||
```
|
||||
|
||||
### Load and return
|
||||
|
||||
### AJAX data
|
407
doc/scripts/build.mjs
Normal file
407
doc/scripts/build.mjs
Normal file
@ -0,0 +1,407 @@
|
||||
import {deleteAsync} from 'del';
|
||||
import {exec, spawn} from 'child_process';
|
||||
import {globby} from 'globby';
|
||||
import browserSync from 'browser-sync';
|
||||
import chalk from 'chalk';
|
||||
import commandLineArgs from 'command-line-args';
|
||||
import copy from 'recursive-copy';
|
||||
import esbuild from 'esbuild';
|
||||
import fs from 'fs/promises';
|
||||
import getPort, {portNumbers} from 'get-port';
|
||||
import ora from 'ora';
|
||||
import util from 'util';
|
||||
import * as path from 'path';
|
||||
import {readFileSync} from 'fs';
|
||||
import {replace} from 'esbuild-plugin-replace';
|
||||
|
||||
const {serve, dev} = commandLineArgs([
|
||||
{name: 'serve', type: Boolean},
|
||||
{name: 'dev', type: Boolean}
|
||||
]);
|
||||
const outdir = 'doc/dist';
|
||||
const cdndir = 'cdn';
|
||||
const sitedir = 'doc/dist/site';
|
||||
const spinner = ora({hideCursor: false}).start();
|
||||
const execPromise = util.promisify(exec);
|
||||
let childProcess;
|
||||
let buildResults;
|
||||
|
||||
const bundleDirectories = [outdir];
|
||||
let packageData = JSON.parse(readFileSync(path.join(process.cwd(), 'package.json'), 'utf-8'));
|
||||
const egwVersion = JSON.stringify(packageData.version.toString());
|
||||
|
||||
//
|
||||
// Runs 11ty and builds the docs. The returned promise resolves after the initial publish has completed. The child
|
||||
// process and an array of strings containing any output are included in the resolved promise.
|
||||
//
|
||||
// To debug:
|
||||
// > DEBUG=Eleventy* npx @11ty/eleventy
|
||||
//
|
||||
async function buildTheDocs(watch = false)
|
||||
{
|
||||
return new Promise(async (resolve, reject) =>
|
||||
{
|
||||
const afterSignal = '[eleventy.after]';
|
||||
const args = ['@11ty/eleventy', '--quiet'];
|
||||
const output = [];
|
||||
|
||||
if (watch)
|
||||
{
|
||||
args.push('--watch');
|
||||
args.push('--incremental');
|
||||
}
|
||||
|
||||
// To debug use this in terminal: DEBUG=Eleventy* npx @11ty/eleventy
|
||||
const child = spawn('npx', args, {
|
||||
stdio: 'pipe',
|
||||
cwd: 'doc/etemplate2',
|
||||
shell: true // for Windows
|
||||
});
|
||||
|
||||
child.stdout.on('data', data =>
|
||||
{
|
||||
if (data.includes(afterSignal))
|
||||
{
|
||||
return;
|
||||
} // don't log the signal
|
||||
output.push(data.toString());
|
||||
});
|
||||
|
||||
if (watch)
|
||||
{
|
||||
// The process doesn't terminate in watch mode so, before resolving, we listen for a known signal in stdout that
|
||||
// tells us when the first build completes.
|
||||
child.stdout.on('data', data =>
|
||||
{
|
||||
if (data.includes(afterSignal))
|
||||
{
|
||||
resolve({child, output});
|
||||
}
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
child.on('close', () =>
|
||||
{
|
||||
resolve({child, output});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// Builds the source with esbuild.
|
||||
//
|
||||
async function buildTheSource()
|
||||
{
|
||||
const alwaysExternal = ['@lit'];
|
||||
|
||||
const cdnConfig = {
|
||||
format: 'esm',
|
||||
target: 'es2017',
|
||||
entryPoints: [
|
||||
//
|
||||
// NOTE: Entry points must be mapped in package.json > exports, otherwise users won't be able to import them!
|
||||
//
|
||||
// The whole shebang
|
||||
'./src/shoelace.ts',
|
||||
// The auto-loader
|
||||
'./src/shoelace-autoloader.ts',
|
||||
// Components
|
||||
...(await globby('./src/components/**/!(*.(style|test)).ts')),
|
||||
// Translations
|
||||
...(await globby('./src/translations/**/*.ts')),
|
||||
// Public utilities
|
||||
...(await globby('./src/utilities/**/!(*.(style|test)).ts')),
|
||||
// Theme stylesheets
|
||||
...(await globby('./src/themes/**/!(*.test).ts')),
|
||||
// React wrappers
|
||||
...(await globby('./src/react/**/*.ts'))
|
||||
],
|
||||
outdir: cdndir,
|
||||
chunkNames: 'chunks/[name].[hash]',
|
||||
define: {
|
||||
// Floating UI requires this to be set
|
||||
'process.env.NODE_ENV': '"production"'
|
||||
},
|
||||
bundle: true,
|
||||
//
|
||||
// We don't bundle certain dependencies in the unbundled build. This ensures we ship bare module specifiers,
|
||||
// allowing end users to better optimize when using a bundler. (Only packages that ship ESM can be external.)
|
||||
//
|
||||
// We never bundle React or @lit-labs/react though!
|
||||
//
|
||||
external: alwaysExternal,
|
||||
splitting: true,
|
||||
plugins: [
|
||||
replace({
|
||||
__EGROUPWARE_VERSION__: egwVersion
|
||||
})
|
||||
]
|
||||
};
|
||||
|
||||
const npmConfig = {
|
||||
...cdnConfig,
|
||||
external: undefined,
|
||||
minify: false,
|
||||
packages: 'external',
|
||||
outdir
|
||||
};
|
||||
|
||||
if (serve)
|
||||
{
|
||||
// Use the context API to allow incremental dev builds
|
||||
const contexts = await Promise.all([esbuild.context(cdnConfig), esbuild.context(npmConfig)]);
|
||||
await Promise.all(contexts.map(context => context.rebuild()));
|
||||
return contexts;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Use the standard API for production builds
|
||||
return await Promise.all([esbuild.build(cdnConfig), esbuild.build(npmConfig)]);
|
||||
}
|
||||
}
|
||||
|
||||
async function rollup(watch = false)
|
||||
{
|
||||
return new Promise(async (resolve, reject) =>
|
||||
{
|
||||
const afterSignal = '[rollup.after]';
|
||||
const args = ['--silent'];
|
||||
const output = [];
|
||||
|
||||
if (watch)
|
||||
{
|
||||
args.push('--watch');
|
||||
args.push('--incremental');
|
||||
}
|
||||
|
||||
// To debug use this in terminal: DEBUG=Eleventy* npx @11ty/eleventy
|
||||
const child = spawn('rollup', args, {
|
||||
stdio: 'pipe',
|
||||
cwd: '.',
|
||||
shell: true // for Windows
|
||||
});
|
||||
|
||||
child.stdout.on('data', data =>
|
||||
{
|
||||
if (data.includes(afterSignal))
|
||||
{
|
||||
return;
|
||||
} // don't log the signal
|
||||
output.push(data.toString());
|
||||
});
|
||||
|
||||
// Not even waiting
|
||||
resolve({child, output});
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// Called on SIGINT or SIGTERM to cleanup the build and child processes.
|
||||
//
|
||||
function handleCleanup()
|
||||
{
|
||||
buildResults.forEach(result => result.dispose());
|
||||
|
||||
if (childProcess)
|
||||
{
|
||||
childProcess.kill('SIGINT');
|
||||
}
|
||||
|
||||
process.exit();
|
||||
}
|
||||
|
||||
//
|
||||
// Helper function to draw a spinner while tasks run.
|
||||
//
|
||||
async function nextTask(label, action)
|
||||
{
|
||||
spinner.text = label;
|
||||
spinner.start();
|
||||
|
||||
try
|
||||
{
|
||||
await action();
|
||||
spinner.stop();
|
||||
console.log(`${chalk.green('✔')} ${label}`);
|
||||
}
|
||||
catch (err)
|
||||
{
|
||||
spinner.stop();
|
||||
console.error(`${chalk.red('✘')} ${err}`);
|
||||
if (err.stdout)
|
||||
{
|
||||
console.error(chalk.red(err.stdout));
|
||||
}
|
||||
if (err.stderr)
|
||||
{
|
||||
console.error(chalk.red(err.stderr));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
await nextTask('Cleaning up the previous build', async () =>
|
||||
{
|
||||
await Promise.all([deleteAsync(sitedir), ...bundleDirectories.map(dir => deleteAsync(dir))]);
|
||||
await fs.mkdir(outdir, {recursive: true});
|
||||
});
|
||||
|
||||
await nextTask('Generating component metadata', () =>
|
||||
{
|
||||
return Promise.all(
|
||||
bundleDirectories.map(dir =>
|
||||
{
|
||||
return execPromise(`node doc/scripts/metadata.mjs --outdir "${dir}"`, {stdio: 'inherit'});
|
||||
})
|
||||
);
|
||||
});
|
||||
/*
|
||||
await nextTask('Generating themes', () =>
|
||||
{
|
||||
return execPromise(`node scripts/make-themes.js --outdir "${outdir}"`, {stdio: 'inherit'});
|
||||
});
|
||||
*/
|
||||
/* We don't do these
|
||||
await nextTask('Running the TypeScript compiler', () =>
|
||||
{
|
||||
return execPromise(`tsc --project ./tsconfig.json --outdir "${outdir}"`, {stdio: 'inherit'});
|
||||
});
|
||||
await nextTask('Building source files', async () =>
|
||||
{
|
||||
buildResults = await buildTheSource();
|
||||
});
|
||||
*/
|
||||
|
||||
// EGroupware way of packaging
|
||||
// We can't watch
|
||||
await nextTask('Rolling up', async () =>
|
||||
{
|
||||
await rollup(dev);
|
||||
});
|
||||
|
||||
|
||||
// Launch the dev server
|
||||
if (serve)
|
||||
{
|
||||
let result;
|
||||
|
||||
// Spin up Eleventy and Wait for the search index to appear before proceeding. The search index is generated during
|
||||
// eleventy.after, so it appears after the docs are fully published. This is kinda hacky, but here we are.
|
||||
// Kick off the Eleventy dev server with --watch and --incremental
|
||||
await nextTask('Building docs', async () =>
|
||||
{
|
||||
result = await buildTheDocs(true);
|
||||
});
|
||||
|
||||
const bs = browserSync.create();
|
||||
const port = await getPort({port: portNumbers(4000, 4999)});
|
||||
const browserSyncConfig = {
|
||||
startPath: '/',
|
||||
port,
|
||||
logLevel: 'silent',
|
||||
logPrefix: '[egw]',
|
||||
logFileChanges: true,
|
||||
notify: false,
|
||||
single: false,
|
||||
ghostMode: false,
|
||||
server: {
|
||||
baseDir: sitedir,
|
||||
routes: {
|
||||
'/dist': './cdn'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Launch browser sync
|
||||
bs.init(browserSyncConfig, () =>
|
||||
{
|
||||
const url = `http://localhost:${port}`;
|
||||
console.log(chalk.cyan(`\n🥾 The dev server is available at ${url}`));
|
||||
|
||||
// Log deferred output
|
||||
if (result.output.length > 0)
|
||||
{
|
||||
console.log('\n' + result.output.join('\n'));
|
||||
}
|
||||
|
||||
// Log output that comes later on
|
||||
result.child.stdout.on('data', data =>
|
||||
{
|
||||
console.log(data.toString());
|
||||
});
|
||||
});
|
||||
|
||||
// Rebuild and reload when source files change
|
||||
bs.watch('src/**/!(*.test).*').on('change', async filename =>
|
||||
{
|
||||
console.log('[build] File changed: ', filename);
|
||||
|
||||
try
|
||||
{
|
||||
const isTheme = /^src\/themes/.test(filename);
|
||||
const isStylesheet = /(\.css|\.styles\.ts)$/.test(filename);
|
||||
|
||||
// Rebuild the source
|
||||
const rebuildResults = buildResults.map(result => result.rebuild());
|
||||
await Promise.all(rebuildResults);
|
||||
|
||||
// Rebuild stylesheets when a theme file changes
|
||||
if (isTheme)
|
||||
{
|
||||
await Promise.all(
|
||||
bundleDirectories.map(dir =>
|
||||
{
|
||||
execPromise(`node scripts/make-themes.js --outdir "${dir}"`, {stdio: 'inherit'});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Rebuild metadata (but not when styles are changed)
|
||||
if (!isStylesheet)
|
||||
{
|
||||
await Promise.all(
|
||||
bundleDirectories.map(dir =>
|
||||
{
|
||||
return execPromise(`node scripts/make-metadata.js --outdir "${dir}"`, {stdio: 'inherit'});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
bs.reload();
|
||||
}
|
||||
catch (err)
|
||||
{
|
||||
console.error(chalk.red(err));
|
||||
}
|
||||
});
|
||||
|
||||
// Reload without rebuilding when the docs change
|
||||
bs.watch([`${sitedir}/**/*.*`]).on('change', filename =>
|
||||
{
|
||||
bs.reload();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Build for production
|
||||
if (!serve)
|
||||
{
|
||||
let result;
|
||||
|
||||
await nextTask('Building the docs', async () =>
|
||||
{
|
||||
result = await buildTheDocs();
|
||||
});
|
||||
|
||||
// Log deferred output
|
||||
if (result.output.length > 0)
|
||||
{
|
||||
console.log('\n' + result.output.join('\n'));
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup on exit
|
||||
process.on('SIGINT', handleCleanup);
|
||||
process.on('SIGTERM', handleCleanup);
|
14
doc/scripts/metadata.mjs
Normal file
14
doc/scripts/metadata.mjs
Normal file
@ -0,0 +1,14 @@
|
||||
//
|
||||
// This script runs the Custom Elements Manifest analyzer to generate custom-elements.json
|
||||
//
|
||||
|
||||
import {execSync} from 'child_process';
|
||||
import commandLineArgs from 'command-line-args';
|
||||
|
||||
const {outdir} = commandLineArgs([
|
||||
{name: 'outdir', type: String},
|
||||
{name: 'watch', type: Boolean}
|
||||
]);
|
||||
|
||||
execSync(`cem analyze --config "doc/etemplate2/custom-elements-manifest.config.mjs" --outdir "${outdir}"`, {stdio: 'inherit'});
|
||||
//execSync(`cem analyze --globs "api/js/etemplate/Et2Widget" --outdir "${outdir}"`, {stdio: 'inherit'});
|
@ -1070,7 +1070,8 @@ div#mail-index_nm.splitter-pane {min-height: 100px;}
|
||||
min-width: 7em;
|
||||
color: darkgrey;
|
||||
}
|
||||
.mailPreviewHeaders et2-select-email::part(control) {
|
||||
|
||||
.mailPreviewHeaders et2-select-email::part(combobox) {
|
||||
border: none;
|
||||
}
|
||||
#popupMainDiv {height: 100%}
|
||||
|
@ -15,7 +15,8 @@ use EGroupware\Api;
|
||||
/**
|
||||
* Ajax methods for notifications
|
||||
*/
|
||||
class notifications_ajax {
|
||||
class notifications_ajax
|
||||
{
|
||||
/**
|
||||
* Appname
|
||||
*/
|
||||
@ -36,6 +37,11 @@ class notifications_ajax {
|
||||
*/
|
||||
const _type = 'base';
|
||||
|
||||
/**
|
||||
* Do NOT consider notifications older than this
|
||||
*/
|
||||
const CUT_OFF_DATE = '-30days';
|
||||
|
||||
/**
|
||||
* holds account object for user to notify
|
||||
*
|
||||
@ -64,22 +70,6 @@ class notifications_ajax {
|
||||
*/
|
||||
private $db;
|
||||
|
||||
/**
|
||||
* holds the users session data
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
var $session_data;
|
||||
|
||||
/**
|
||||
* holds the users session data defaults
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
var $session_data_defaults = array(
|
||||
'notified_mail_uids' => array(),
|
||||
);
|
||||
|
||||
/**
|
||||
* the xml response object
|
||||
*
|
||||
@ -98,15 +88,11 @@ class notifications_ajax {
|
||||
* constructor
|
||||
*
|
||||
*/
|
||||
public function __construct() {
|
||||
public function __construct()
|
||||
{
|
||||
$this->response = Api\Json\Response::get();
|
||||
$this->recipient = (object)$GLOBALS['egw']->accounts->read($GLOBALS['egw_info']['user']['account_id']);
|
||||
|
||||
$this->config = (object)Api\Config::read(self::_appname);
|
||||
|
||||
$prefs = new Api\Preferences($this->recipient->account_id);
|
||||
$this->preferences = $prefs->read();
|
||||
|
||||
$this->db = $GLOBALS['egw']->db;
|
||||
|
||||
$this->isPushServer = Api\Cache::getInstance('notifications', 'isPushServer', function ()
|
||||
@ -147,18 +133,9 @@ class notifications_ajax {
|
||||
*
|
||||
* @param array $notifymessages one or multiple notify_id(s)
|
||||
*/
|
||||
public function delete_message($notifymessages)
|
||||
public function delete_message(array $notifymessages)
|
||||
{
|
||||
$notify_ids = $this->fetch_notify_ids($notifymessages);
|
||||
if (!empty($notify_ids))
|
||||
{
|
||||
$this->db->delete(self::_notification_table,array(
|
||||
'notify_id' => $notify_ids,
|
||||
'account_id' => $this->recipient->account_id,
|
||||
'notify_type' => self::_type
|
||||
),__LINE__,__FILE__,self::_appname);
|
||||
}
|
||||
$this->response->data(['deleted'=>$notify_ids]);
|
||||
$this->update($notifymessages, null); // null = delete
|
||||
}
|
||||
|
||||
/**
|
||||
@ -172,80 +149,95 @@ class notifications_ajax {
|
||||
* this status has been used more specifically for browser type
|
||||
* of notifications.
|
||||
*/
|
||||
public function update_status($notifymessages, $status = "SEEN")
|
||||
public function update_status(array $notifymessages, $status = "SEEN")
|
||||
{
|
||||
$notify_ids = $this->fetch_notify_ids($notifymessages);
|
||||
if (!empty($notify_ids))
|
||||
{
|
||||
$this->db->update(self::_notification_table,array('notify_status' => $status),array(
|
||||
'notify_id' => $notify_ids,
|
||||
'account_id' => $this->recipient->account_id,
|
||||
'notify_type' => self::_type
|
||||
),__LINE__,__FILE__,self::_appname);
|
||||
}
|
||||
$this->update($notifymessages, $status);
|
||||
}
|
||||
|
||||
/**
|
||||
* gets all relevant notify ids based on given notify message data
|
||||
* @param $notifymessages
|
||||
* Update or delete the given notification messages, incl. not explicitly mentioned ones with same app:id
|
||||
*
|
||||
* @param array $notifymessages
|
||||
* @param string|null $status use null to delete
|
||||
* @return array
|
||||
*/
|
||||
public function fetch_notify_ids ($notifymessages)
|
||||
protected function update(array $notifymessages, $status='SEEN')
|
||||
{
|
||||
$notify_ids = [];
|
||||
|
||||
$notify_ids = $app_ids = [];
|
||||
foreach ($notifymessages as $data)
|
||||
{
|
||||
if (is_array($data) && $data['id'])
|
||||
if (is_array($data) && !empty($data['id']))
|
||||
{
|
||||
array_push($notify_ids, (string)$data['id']);
|
||||
if (is_array($data['data'])) $notify_ids = array_unique(array_merge($notify_ids, $this->search_in_notify_data($data['data']['id'], $data['data']['app'])));
|
||||
if (is_array($data['data'] ?? null) && !empty($data['data']['id']))
|
||||
{
|
||||
$app_ids[$data['data']['app']][$data['data']['id']] = $data['data']['id'];
|
||||
}
|
||||
$notify_ids[] = $data['id'];
|
||||
}
|
||||
else
|
||||
{
|
||||
array_push($notify_ids, (string)$data);
|
||||
$notify_ids[] = $data;
|
||||
}
|
||||
|
||||
}
|
||||
return $notify_ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all notify_ids relevant to the entry
|
||||
* @param $_id
|
||||
* @param $_appname
|
||||
* @return array
|
||||
*/
|
||||
public function search_in_notify_data($_id, $_appname)
|
||||
{
|
||||
$ret = [];
|
||||
if ($_id && $_appname)
|
||||
{
|
||||
try {
|
||||
// mariaDB supported query
|
||||
$ret = $this->db->select(self::_notification_table, 'notify_id', array(
|
||||
$cut_off = $this->db->quote(Api\DateTime::to(self::CUT_OFF_DATE, Api\DateTime::DATABASE));
|
||||
try {
|
||||
// MariaDB code using JSON_EXTRACT()
|
||||
foreach($app_ids as $app => $ids)
|
||||
{
|
||||
$where = [
|
||||
'account_id' => $this->recipient->account_id,
|
||||
'notify_type' => self::_type,
|
||||
'notify_data->"$.appname"' => $_appname,
|
||||
'notify_data->"$.data.id"' => $_id
|
||||
),
|
||||
__LINE__,__FILE__,0 ,'ORDER BY notify_id DESC',self::_appname);
|
||||
}
|
||||
catch (Api\Db\Exception $e) {
|
||||
// do it manual for all other DB
|
||||
foreach($this->db->select(self::_notification_table, '*', array(
|
||||
'account_id' => $this->recipient->account_id,
|
||||
'notify_type' => self::_type
|
||||
),
|
||||
__LINE__,__FILE__,0 ,'ORDER BY notify_id DESC',self::_appname) as $row)
|
||||
"JSON_EXTRACT(notify_data, '$.appname') = ".$this->db->quote($app),
|
||||
"JSON_EXTRACT(notify_data, '$.data.id') IN (".implode(',', array_map([$this->db, 'quote'], array_unique($ids))).')',
|
||||
'notify_created > '.$cut_off,
|
||||
];
|
||||
if (isset($status))
|
||||
{
|
||||
$data = json_decode($row['notify_data'], true);
|
||||
if ($data['appname'] == $_appname && $data['data']['id'] == $_id) $ret[] = $row['notify_id'];
|
||||
$this->db->update(self::_notification_table, ['notify_status' => $status], $where, __LINE__, __FILE__, self::_appname);
|
||||
}
|
||||
else
|
||||
{
|
||||
$this->db->delete(self::_notification_table, $where, __LINE__, __FILE__, self::_appname);
|
||||
}
|
||||
}
|
||||
}
|
||||
return $ret;
|
||||
// other DBs
|
||||
catch (Api\Db\Exception $e) {
|
||||
foreach($this->db->select(self::_notification_table, 'notify_id,notify_data', [
|
||||
'account_id' => $this->recipient->account_id,
|
||||
'notify_type' => self::_type,
|
||||
'notify_created > '.$cut_off,
|
||||
"notify_data <> '[]'", // does not return NULL or '[]' rows
|
||||
]) as $row)
|
||||
{
|
||||
if (($data = json_decode($row['notify_data'], true)) &&
|
||||
isset($data['data']['id']) && in_array($data['data']['id'], $app_ids[$data['appname']] ?? []))
|
||||
{
|
||||
$notify_ids[] = $row['notify_id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
$where = [
|
||||
'notify_id' => array_unique($notify_ids),
|
||||
'account_id' => $this->recipient->account_id,
|
||||
'notify_type' => self::_type
|
||||
];
|
||||
if (isset($status))
|
||||
{
|
||||
$this->db->update(self::_notification_table, ['notify_status' => $status], $where, __LINE__, __FILE__, self::_appname);
|
||||
}
|
||||
else
|
||||
{
|
||||
$this->db->delete(self::_notification_table, $where, __LINE__, __FILE__, self::_appname);
|
||||
}
|
||||
|
||||
// cleanup messages older than our cut-off-date
|
||||
$this->db->delete(self::_notification_table, [
|
||||
'notify_created <= '.$cut_off,
|
||||
'notify_type' => self::_type
|
||||
], __LINE__, __FILE__, self::_appname);
|
||||
}
|
||||
|
||||
/**
|
||||
* gets all egwpopup notifications for calling user
|
||||
*
|
||||
@ -257,30 +249,4 @@ class notifications_ajax {
|
||||
$this->response->apply('app.notifications.append', array($entries['rows']??[], $browserNotify, $entries['total']??0));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* restores the users session data for notifications
|
||||
*
|
||||
* @return boolean true
|
||||
*/
|
||||
private function restore_session_data() {
|
||||
$session_data = Api\Cache::getSession(self::_appname, 'session_data');
|
||||
if(is_array($session_data)) {
|
||||
$this->session_data = $session_data;
|
||||
} else {
|
||||
$this->session_data = $this->session_data_defaults;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* saves the users session data for notifications
|
||||
*
|
||||
* @return boolean true
|
||||
*/
|
||||
private function save_session_data() {
|
||||
Api\Cache::setSession(self::_appname, 'session_data', $this->session_data);
|
||||
return true;
|
||||
}
|
||||
}
|
@ -21,8 +21,8 @@ use EGroupware\Api;
|
||||
* out the table to look if there is a notificaton for this
|
||||
* client. The second stage is done in class.notifications_ajax.inc.php
|
||||
*/
|
||||
class notifications_popup implements notifications_iface {
|
||||
|
||||
class notifications_popup implements notifications_iface
|
||||
{
|
||||
/**
|
||||
* Appname
|
||||
*/
|
||||
@ -120,13 +120,14 @@ class notifications_popup implements notifications_iface {
|
||||
* @param array $_user_sessions
|
||||
* @param array $_data
|
||||
*/
|
||||
private function save($_message, $_data) {
|
||||
private function save($_message, $_data)
|
||||
{
|
||||
$result = $this->db->insert( self::_notification_table, array(
|
||||
'account_id' => $this->recipient->account_id,
|
||||
'notify_message' => $_message,
|
||||
'notify_type' => self::_type,
|
||||
'notify_data' => is_array($_data) ? json_encode($_data) : NULL,
|
||||
'notify_created' => Api\DateTime::user2server('now'),
|
||||
'notify_data' => $_data && is_array($_data) ? json_encode($_data) : NULL,
|
||||
'notify_created' => new Api\DateTime(),
|
||||
), false,__LINE__,__FILE__,self::_appname);
|
||||
if ($result === false) throw new Exception("Can't save notification into SQL table");
|
||||
$push = new Api\Json\Push($this->recipient->account_id);
|
||||
@ -136,50 +137,83 @@ class notifications_popup implements notifications_iface {
|
||||
|
||||
|
||||
/**
|
||||
* read all notification messages for given recipient
|
||||
* Read the 100 most recent notification messages for given recipient
|
||||
*
|
||||
* We use a cut-off-date of 30day, not returning anything older!
|
||||
*
|
||||
* @param $_account_id
|
||||
* @param int $num_rows
|
||||
* @return array
|
||||
*/
|
||||
public static function read($_account_id)
|
||||
public static function read($_account_id, int $num_rows=100)
|
||||
{
|
||||
if (!$_account_id) return [];
|
||||
|
||||
$rs = $GLOBALS['egw']->db->select(self::_notification_table, '*', array(
|
||||
'account_id' => $_account_id,
|
||||
'notify_type' => self::_type
|
||||
),
|
||||
__LINE__,__FILE__,0 ,'ORDER BY notify_id DESC',self::_appname, 100);
|
||||
// Fetch the total
|
||||
$total = $GLOBALS['egw']->db->select(self::_notification_table, 'COUNT(*)', array(
|
||||
'account_id' => $_account_id,
|
||||
'notify_type' => self::_type
|
||||
),
|
||||
__LINE__,__FILE__,0 ,'',self::_appname)->fetchColumn();
|
||||
$result = array();
|
||||
if ($rs->NumRows() > 0) {
|
||||
foreach ($rs as $notification) {
|
||||
$actions = null;
|
||||
$data = json_decode($notification['notify_data'], true);
|
||||
if (!empty($data['appname']) && !empty($data['data']))
|
||||
{
|
||||
$_actions = Api\Hooks::process (array(
|
||||
'location' => 'notifications_actions',
|
||||
'data' => $data['data']
|
||||
), $data['appname'], true);
|
||||
$actions = $_actions[$data['appname']];
|
||||
}
|
||||
$result[] = array(
|
||||
'id' => $notification['notify_id'],
|
||||
'message' => $notification['notify_message'],
|
||||
'status' => $notification['notify_status'],
|
||||
'created' => Api\DateTime::server2user($notification['notify_created']),
|
||||
'current' => new Api\DateTime('now'),
|
||||
'actions' => is_array($actions)?$actions:NULL,
|
||||
'extra_data' => $data['data'] ?? [],
|
||||
);
|
||||
/** @var Api\Db $db */
|
||||
$db = $GLOBALS['egw']->db;
|
||||
|
||||
$result = [];
|
||||
if (($total = $db->select(self::_notification_table, 'COUNT(*)', [
|
||||
'account_id' => $_account_id,
|
||||
'notify_type' => self::_type,
|
||||
'notify_created > '.($cut_off=$db->quote(Api\DateTime::to(notifications_ajax::CUT_OFF_DATE, Api\DateTime::DATABASE))),
|
||||
], __LINE__, __FILE__, false, '', self::_appname)->fetchColumn()))
|
||||
{
|
||||
$n = 0;
|
||||
$chunk_size = 150;
|
||||
do
|
||||
{
|
||||
$notification = null;
|
||||
foreach ($rs=$db->select(self::_notification_table, '*', [
|
||||
'account_id' => $_account_id,
|
||||
'notify_type' => self::_type,
|
||||
'notify_created > ' . $cut_off,
|
||||
], __LINE__, __FILE__, $n, 'ORDER BY notify_id DESC', self::_appname, $chunk_size) as $notification)
|
||||
{
|
||||
$actions = null;
|
||||
$data = json_decode($notification['notify_data'], true);
|
||||
if (!empty($data['appname']) && !empty($data['data']))
|
||||
{
|
||||
$_actions = Api\Hooks::process(array(
|
||||
'location' => 'notifications_actions',
|
||||
'data' => $data['data']
|
||||
), $data['appname'], true);
|
||||
$actions = $_actions[$data['appname']];
|
||||
}
|
||||
$data = [
|
||||
'id' => $notification['notify_id'],
|
||||
'message' => $notification['notify_message'],
|
||||
'status' => $notification['notify_status'],
|
||||
'created' => Api\DateTime::server2user($notification['notify_created']),
|
||||
'current' => new Api\DateTime('now'),
|
||||
'actions' => is_array($actions) ? $actions : NULL,
|
||||
'extra_data' => $data['data'] ?? [],
|
||||
];
|
||||
// aggregate by app:id reporting only the newest entry
|
||||
if (!empty($data['extra_data']['id']))
|
||||
{
|
||||
if (!isset($result[$id = $data['extra_data']['app'] . ':' . $data['extra_data']['id']]))
|
||||
{
|
||||
$result[$id] = $data;
|
||||
}
|
||||
else
|
||||
{
|
||||
$total--;
|
||||
/* in case we want to show all
|
||||
$result['id']['others'][] = $data;
|
||||
*/
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
$result[] = $data;
|
||||
}
|
||||
}
|
||||
$n += $chunk_size;
|
||||
}
|
||||
return ['rows' => $result, 'total'=> $total];
|
||||
while(!$notification || count($result) < min($num_rows, $total));
|
||||
|
||||
return ['rows' => array_values($result), 'total'=> $total];
|
||||
}
|
||||
}
|
||||
|
||||
@ -273,9 +307,10 @@ class notifications_popup implements notifications_iface {
|
||||
*
|
||||
* @param settings array with keys account_id and new_owner (new_owner is optional)
|
||||
*/
|
||||
public static function deleteaccount($settings) {
|
||||
public static function deleteaccount($settings)
|
||||
{
|
||||
$GLOBALS['egw']->db->delete( self::_notification_table, array(
|
||||
'account_id' => $settings['account_id']
|
||||
),__LINE__,__FILE__,self::_appname);
|
||||
}
|
||||
}
|
||||
}
|
14312
package-lock.json
generated
14312
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
27
package.json
27
package.json
@ -7,15 +7,18 @@
|
||||
"scripts": {
|
||||
"build": "rollup -c",
|
||||
"build:watch": "rollup -cw",
|
||||
"build:dev": "node doc/scripts/build.mjs --dev --serve",
|
||||
"jstest": "tsc &> /dev/null; web-test-runner",
|
||||
"jstest:watch": "web-test-runner --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@11ty/eleventy": "^2.0.1",
|
||||
"@babel/core": "^7.14.6",
|
||||
"@babel/plugin-proposal-class-properties": "^7.18.6",
|
||||
"@babel/plugin-proposal-decorators": "^7.22.10",
|
||||
"@babel/preset-env": "^7.20.2",
|
||||
"@babel/preset-typescript": "^7.14.5",
|
||||
"@custom-elements-manifest/analyzer": "^0.8.4",
|
||||
"@interactjs/interactjs": "^1.10.11",
|
||||
"@open-wc/testing": "^3.0.3",
|
||||
"@rollup/plugin-babel": "^5.3.0",
|
||||
@ -23,16 +26,38 @@
|
||||
"@rollup/plugin-typescript": "^8.2.1",
|
||||
"@types/chai": "^4.2.21",
|
||||
"@types/mocha": "^8.2.3",
|
||||
"@web/dev-server-esbuild": "^0.2.14",
|
||||
"@web/dev-server-esbuild": "^0.4.1",
|
||||
"@web/dev-server-rollup": "^0.3.9",
|
||||
"@web/test-runner": "^0.13.16",
|
||||
"@web/test-runner-playwright": "^0.8.8",
|
||||
"browser-sync": "^2.29.3",
|
||||
"cem": "^1.0.4",
|
||||
"change-case": "^4.1.2",
|
||||
"custom-element-jet-brains-integration": "^1.2.1",
|
||||
"custom-element-vs-code-integration": "^1.2.1",
|
||||
"del": "^7.1.0",
|
||||
"esbuild": "^0.19.3",
|
||||
"esbuild-plugin-replace": "^1.4.0",
|
||||
"get-port": "^7.0.0",
|
||||
"globby": "^13.2.2",
|
||||
"grunt": "^1.5.3",
|
||||
"grunt-contrib-cssmin": "^2.2.1",
|
||||
"jsdom": "^22.1.0",
|
||||
"lunr": "^2.3.9",
|
||||
"markdown-it": "^13.0.1",
|
||||
"markdown-it-container": "^3.0.0",
|
||||
"markdown-it-ins": "^3.0.1",
|
||||
"markdown-it-kbd": "^2.2.2",
|
||||
"markdown-it-mark": "^3.0.1",
|
||||
"markdown-it-replace-it": "^1.0.0",
|
||||
"ora": "^7.0.1",
|
||||
"prettier": "^3.0.3",
|
||||
"prismjs": "^1.29.0",
|
||||
"rimraf": "^3.0.2",
|
||||
"rollup": "^2.79.1",
|
||||
"rollup-plugin-terser": "^7.0.2",
|
||||
"sinon": "^11.1.2",
|
||||
"smartquotes": "^2.3.2",
|
||||
"terser": "^4.8.1",
|
||||
"typescript": "^3.9.7"
|
||||
},
|
||||
|
@ -5613,6 +5613,11 @@ div.timesheet_timer {
|
||||
#egw_fw_topmenu_info_items #topmenu_info_timer #timer_selectbox sl-option {
|
||||
white-space: nowrap;
|
||||
}
|
||||
#egw_fw_topmenu_info_items #topmenu_info_quick_add #quick_add_selectbox::part(emptyLabel),
|
||||
#egw_fw_topmenu_info_items #topmenu_info_timer #quick_add_selectbox::part(emptyLabel) {
|
||||
/* do NOT show empty label, required for clearing value */
|
||||
display: none;
|
||||
}
|
||||
#egw_fw_topmenu_info_items #topmenu_info_quick_add #quick_add_selectbox::part(form-control-input),
|
||||
#egw_fw_topmenu_info_items #topmenu_info_timer #quick_add_selectbox::part(form-control-input),
|
||||
#egw_fw_topmenu_info_items #topmenu_info_quick_add #timer_selectbox::part(form-control-input),
|
||||
|
@ -5593,6 +5593,11 @@ div.timesheet_timer {
|
||||
#egw_fw_topmenu_info_items #topmenu_info_timer #timer_selectbox sl-option {
|
||||
white-space: nowrap;
|
||||
}
|
||||
#egw_fw_topmenu_info_items #topmenu_info_quick_add #quick_add_selectbox::part(emptyLabel),
|
||||
#egw_fw_topmenu_info_items #topmenu_info_timer #quick_add_selectbox::part(emptyLabel) {
|
||||
/* do NOT show empty label, required for clearing value */
|
||||
display: none;
|
||||
}
|
||||
#egw_fw_topmenu_info_items #topmenu_info_quick_add #quick_add_selectbox::part(form-control-input),
|
||||
#egw_fw_topmenu_info_items #topmenu_info_timer #quick_add_selectbox::part(form-control-input),
|
||||
#egw_fw_topmenu_info_items #topmenu_info_quick_add #timer_selectbox::part(form-control-input),
|
||||
|
@ -5603,6 +5603,11 @@ div.timesheet_timer {
|
||||
#egw_fw_topmenu_info_items #topmenu_info_timer #timer_selectbox sl-option {
|
||||
white-space: nowrap;
|
||||
}
|
||||
#egw_fw_topmenu_info_items #topmenu_info_quick_add #quick_add_selectbox::part(emptyLabel),
|
||||
#egw_fw_topmenu_info_items #topmenu_info_timer #quick_add_selectbox::part(emptyLabel) {
|
||||
/* do NOT show empty label, required for clearing value */
|
||||
display: none;
|
||||
}
|
||||
#egw_fw_topmenu_info_items #topmenu_info_quick_add #quick_add_selectbox::part(form-control-input),
|
||||
#egw_fw_topmenu_info_items #topmenu_info_timer #quick_add_selectbox::part(form-control-input),
|
||||
#egw_fw_topmenu_info_items #topmenu_info_quick_add #timer_selectbox::part(form-control-input),
|
||||
|
@ -298,6 +298,11 @@ div.timesheet_timer {
|
||||
}
|
||||
}
|
||||
|
||||
#quick_add_selectbox::part(emptyLabel) {
|
||||
/* do NOT show empty label, required for clearing value */
|
||||
display: none;
|
||||
}
|
||||
|
||||
#quick_add_selectbox::part(form-control-input), #timer_selectbox::part(form-control-input) {
|
||||
border: none !important;
|
||||
}
|
||||
|
@ -5624,6 +5624,11 @@ div.timesheet_timer {
|
||||
#egw_fw_topmenu_info_items #topmenu_info_timer #timer_selectbox sl-option {
|
||||
white-space: nowrap;
|
||||
}
|
||||
#egw_fw_topmenu_info_items #topmenu_info_quick_add #quick_add_selectbox::part(emptyLabel),
|
||||
#egw_fw_topmenu_info_items #topmenu_info_timer #quick_add_selectbox::part(emptyLabel) {
|
||||
/* do NOT show empty label, required for clearing value */
|
||||
display: none;
|
||||
}
|
||||
#egw_fw_topmenu_info_items #topmenu_info_quick_add #quick_add_selectbox::part(form-control-input),
|
||||
#egw_fw_topmenu_info_items #topmenu_info_timer #quick_add_selectbox::part(form-control-input),
|
||||
#egw_fw_topmenu_info_items #topmenu_info_quick_add #timer_selectbox::part(form-control-input),
|
||||
|
Loading…
Reference in New Issue
Block a user