diff --git a/api/js/etemplate/Et2Vfs/Et2VfsPath.styles.ts b/api/js/etemplate/Et2Vfs/Et2VfsPath.styles.ts new file mode 100644 index 0000000000..106a97405a --- /dev/null +++ b/api/js/etemplate/Et2Vfs/Et2VfsPath.styles.ts @@ -0,0 +1,95 @@ +import {css} from 'lit'; + +export default css` + + .form-control-input { + flex: 1; + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.1rem 0.5rem; + + background-color: var(--sl-input-background-color); + border: solid var(--sl-input-border-width) var(--sl-input-border-color); + + border-radius: var(--sl-input-border-radius-medium); + font-size: var(--sl-input-font-size-medium); + overflow-y: auto; + padding-block: 0; + padding-inline: var(--sl-input-spacing-medium); + padding-top: 0.1rem; + padding-bottom: 0.1rem; + + transition: var(--sl-transition-fast) color, var(--sl-transition-fast) border, var(--sl-transition-fast) box-shadow, + var(--sl-transition-fast) background-color; + } + + .vfs-path__value-input { + flex: 1 1 auto; + order: 10; + min-width: 7em; + border: none; + outline: none; + color: var(--input-text-color); + + font-size: var(--sl-input-font-size-medium); + padding-block: 0; + padding-inline: var(--sl-input-spacing-medium); + } + + /* Edit button */ + + .vfs-path__edit { + flex-grow: 0; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: inherit; + color: var(--sl-input-icon-color); + border: none; + background: none; + padding: 0; + transition: var(--sl-transition-fast) color; + cursor: pointer; + margin-left: auto; + } + + /* Breadcrumb directories */ + + sl-breadcrumb-item::part(label) { + color: var(--input-text-color); + } + + sl-breadcrumb-item::part(separator) { + color: var(--input-text-color); + margin: 0 var(--sl-spacing-2x-small); + } + + /* Sizes */ + + .form-control--medium, .form-control--medium .form-control-input { + min-height: var(--sl-input-height-medium); + } + + .form-control--medium .vfs-path__edit { + margin-inline-start: var(--sl-input-spacing-medium); + } + + + /* Readonly */ + + :host([readonly]) .form-control-input { + border: none; + box-shadow: none; + } + + .vfs-path__readonly sl-breadcrumb-item::part(label) { + cursor: initial; + } + + .vfs-path__disabled sl-breadcrumb-item::part(label) { + color: var(--sl-input-color-disabled); + } +`; \ No newline at end of file diff --git a/api/js/etemplate/Et2Vfs/Et2VfsPath.ts b/api/js/etemplate/Et2Vfs/Et2VfsPath.ts new file mode 100644 index 0000000000..2f9a7b4575 --- /dev/null +++ b/api/js/etemplate/Et2Vfs/Et2VfsPath.ts @@ -0,0 +1,290 @@ +/** + * EGroupware eTemplate2 - Vfs path WebComponent + * + * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License + * @package api + * @link https://www.egroupware.org + * @author Nathan Gray + */ + +import {Et2InputWidget} from "../Et2InputWidget/Et2InputWidget"; +import {html, LitElement, nothing} from "lit"; +import shoelace from "../Styles/shoelace"; +import styles from "./Et2VfsPath.styles"; +import {property} from "lit/decorators/property.js"; +import {state} from "lit/decorators/state.js"; +import {classMap} from "lit/directives/class-map.js"; +import {repeat} from "lit/directives/repeat.js"; +import {FileInfo} from "./Et2VfsSelect"; +import {SlBreadcrumbItem} from "@shoelace-style/shoelace"; +import {HasSlotController} from "../Et2Widget/slot"; + +/** + * @summary Display an editable path from the VFS + * @since + * + * @slot prefix - Before the path + * @slot suffix - Like prefix, but after + * @slot help-text - Text that describes how to use the input. Alternatively, you can use the `help-text` attribute. + * + * @event change - Emitted when the control's value changes. + * + * @csspart form-control-input - The textbox's wrapper. + * @csspart form-control-help-text - The help text's wrapper. + * @csspart prefix - The container that wraps the prefix slot. + * @csspart suffix - The container that wraps the suffix slot. + * + */ +export class Et2VfsPath extends Et2InputWidget(LitElement) +{ + static get styles() + { + return [ + shoelace, + ...super.styles, + styles + ]; + } + + /** The component's help text. If you need to display HTML, use the `help-text` slot instead. */ + @property({attribute: 'help-text'}) helpText = ''; + + /* User is directly editing the path as a string */ + @state() editing = false; + + protected readonly hasSlotController = new HasSlotController(this, 'help-text', 'label'); + private _value = "" + + + get _edit() : HTMLInputElement { return this.shadowRoot.querySelector("input");} + + constructor() + { + super(); + + this.handleEditMouseDown = this.handleEditMouseDown.bind(this); + this.handleKeyDown = this.handleKeyDown.bind(this); + this.handlePathClick = this.handlePathClick.bind(this); + } + + @property() + set value(_value : string) + { + try + { + _value = this.egw().decodePath(_value); + } + catch(e) + { + this.set_validation_error('Error! ' + _value); + return; + } + const oldValue = this._value; + this._value = _value; + this.requestUpdate("value", oldValue); + } + + get value() { return this._value;} + + setValue(_value : string | FileInfo) + { + if(typeof _value != "string" && _value.path) + { + _value = _value.path; + } + this.value = _value; + } + + getValue() + { + return (this.readonly || this.disabled) ? null : (this.egw().encodePath(this._value || '')); + } + + public focus() + { + this.edit(); + } + + public blur() + { + this.editing = false; + + this.requestUpdate("editing"); + let oldValue = this.value; + this.value = this._edit.value; + + if(oldValue != this.value) + { + this.updateComplete.then(() => + { + this.dispatchEvent(new Event("change")); + }) + } + } + + public edit() + { + const oldValue = this.editing; + this.editing = true; + + this.requestUpdate("editing", oldValue); + this.updateComplete.then(() => + { + this._edit?.focus(); + }) + } + + protected handleLabelClick() + { + this.edit(); + } + + protected handleEditMouseDown(event : MouseEvent) + { + this.edit(); + } + + protected handleKeyDown(event : KeyboardEvent) + { + switch(event.key) + { + case "Enter": + event.stopPropagation(); + event.preventDefault(); + this.editing = !this.editing; + this.requestUpdate("editing"); + break; + case "Escape": + event.stopPropagation(); + event.preventDefault(); + this.blur(); + break; + + + } + } + + protected handlePathClick(event : MouseEvent) + { + if(event.target instanceof SlBreadcrumbItem && event.composedPath().includes(this)) + { + event.preventDefault(); + event.stopPropagation(); + + const dirs = Array.from(event.target.parentElement.querySelectorAll('sl-breadcrumb-item')) ?? []; + let stopIndex = dirs.indexOf(event.target) + 1; + let newPath = dirs.slice(0, stopIndex).map(d => d.textContent); + if(!(this.disabled || this.readonly)) + { + const oldValue = this.value; + this.value = newPath.join(""); + if(oldValue != this.value) + { + this.updateComplete.then(() => + { + this.dispatchEvent(new Event("change")); + }) + } + } + // Can still click on it when disabled I guess + if(!this.disabled) + { + this.dispatchEvent(new CustomEvent("click", { + bubbles: true, + cancelable: true, + detail: newPath.join("") + })); + } + } + } + + render() + { + const hasLabelSlot = this.hasSlotController.test('label'); + const hasHelpTextSlot = this.hasSlotController.test('help-text'); + const hasLabel = this.label ? true : !!hasLabelSlot; + const hasHelpText = this.helpText ? true : !!hasHelpTextSlot; + const pathParts = this.value === "/" ? [""] : this.value.split('/'); + const isEditable = !(this.disabled || this.readonly); + const editing = this.editing && isEditable; + + return html` +
+ +
this.focus()} + > + + ${editing ? html` + this.blur()} + @keydown=${this.handleKeyDown} + />` : html` + + / + ${repeat(pathParts, (path) => + { + return html` + ${path}`; + })} + + ${!isEditable ? nothing : html` + ` + } + `} + +
+
+ ${this.helpText} +
+
+ `; + } +} + +customElements.define("et2-vfs-path", Et2VfsPath); diff --git a/api/js/etemplate/Et2Vfs/test/Et2VfsPath.test.ts b/api/js/etemplate/Et2Vfs/test/Et2VfsPath.test.ts new file mode 100644 index 0000000000..cb6dd68f37 --- /dev/null +++ b/api/js/etemplate/Et2Vfs/test/Et2VfsPath.test.ts @@ -0,0 +1,86 @@ +import {assert, elementUpdated, fixture, html} from '@open-wc/testing'; +import * as sinon from 'sinon'; +import {inputBasicTests} from "../../Et2InputWidget/test/InputBasicTests"; +import {Et2VfsPath} from "../Et2VfsPath"; + +/** + * Test file for Etemplate webComponent VfsPath + * + */ +window.egw = { + ajaxUrl: () => "", + app: () => "addressbook", + decodePath: (_path : string) => _path, + encodePath: (_path : string) => _path, + image: () => "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBBZG9iZSBJbGx1c3RyYXRvciAxNS4wLjAsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9uOiA2LjAwIEJ1aWxkIDApICAtLT4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+DQo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkViZW5lXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4Ig0KCSB3aWR0aD0iMzJweCIgaGVpZ2h0PSIzMnB4IiB2aWV3Qm94PSIwIDAgMzIgMzIiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDMyIDMyIiB4bWw6c3BhY2U9InByZXNlcnZlIj4NCjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBmaWxsPSIjNjk2OTY5IiBkPSJNNi45NDMsMjguNDUzDQoJYzAuOTA2LDAuNzY1LDIuMDk3LDEuMTI3LDMuMjg2LDEuMTA5YzAuNDMsMC4wMTQsMC44NTItMC4wNjgsMS4yNjUtMC4yMDdjMC42NzktMC4xOCwxLjMyOC0wLjQ1LDEuODY2LTAuOTAyTDI5LjQwMywxNC45DQoJYzEuNzcyLTEuNDk4LDEuNzcyLTMuOTI1LDAtNS40MjJjLTEuNzcyLTEuNDk3LTQuNjQ2LTEuNDk3LTYuNDE4LDBMMTAuMTE5LDIwLjM0OWwtMi4zODktMi40MjRjLTEuNDQtMS40NTctMy43NzItMS40NTctNS4yMTIsMA0KCWMtMS40MzgsMS40Ni0xLjQzOCwzLjgyNSwwLDUuMjgxQzIuNTE4LDIzLjIwNiw1LjQ3NCwyNi45NDcsNi45NDMsMjguNDUzeiIvPg0KPC9zdmc+DQo=", + jsonq: () => Promise.resolve({}), + lang: i => i + "*", + link: i => i, + preference: i => "", + tooltipUnbind: () => {}, + webserverUrl: "", +}; + +let element : Et2VfsPath; + +async function before() +{ + // Create an element to test with, and wait until it's ready + // @ts-ignore + element = await fixture(html` + + + `); + + // Stub egw() + sinon.stub(element, "egw").returns(window.egw); + await elementUpdated(element); + + return element; +} + +describe("Path widget basics", () => +{ + // Setup run before each test + beforeEach(before); + + // Make sure it works + it('is defined', () => + { + assert.instanceOf(element, Et2VfsPath); + }); + + it('has a label', async() => + { + element.set_label("Label set"); + await elementUpdated(element); + + assert.equal(element.querySelector("[slot='label']").textContent, "Label set"); + }); + + it("textbox gets focus when widget is focused", async() => + { + element.focus(); + await elementUpdated(element); + assert.equal(element.shadowRoot.activeElement, element._edit, "Editable path did not get focus when widget got focus"); + }); + + it("blurring widget accepts current text", async() => + { + const value = "/home/test/directory"; + element.focus(); + await elementUpdated(element); + element._edit.value = value; + element.blur(); + await elementUpdated(element); + + assert.equal(element.value, value, "Path was not accepted on blur"); + }); +}); + +inputBasicTests(async() => +{ + const element = await before(); + element.noLang = true; + return element +}, "/home/test", "sl-breadcrumb"); \ No newline at end of file diff --git a/api/js/etemplate/etemplate2.ts b/api/js/etemplate/etemplate2.ts index 4c48d9c556..4ac07e1f1e 100644 --- a/api/js/etemplate/etemplate2.ts +++ b/api/js/etemplate/etemplate2.ts @@ -97,6 +97,7 @@ import './Et2Url/Et2UrlFaxReadonly'; import "./Layout/Et2Split/Et2Split"; import "./Layout/RowLimitedMixin"; import "./Et2Vfs/Et2VfsMime"; +import "./Et2Vfs/Et2VfsPath"; import "./Et2Vfs/Et2VfsSelect"; import "./Et2Vfs/Et2VfsSelectRow"; import "./Et2Vfs/Et2VfsUid";