mirror of
https://github.com/EGroupware/egroupware.git
synced 2024-12-26 00:29:38 +01:00
Et2VfsPath widget
This commit is contained in:
parent
187718ccd8
commit
b19c913006
95
api/js/etemplate/Et2Vfs/Et2VfsPath.styles.ts
Normal file
95
api/js/etemplate/Et2Vfs/Et2VfsPath.styles.ts
Normal file
@ -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);
|
||||
}
|
||||
`;
|
290
api/js/etemplate/Et2Vfs/Et2VfsPath.ts
Normal file
290
api/js/etemplate/Et2Vfs/Et2VfsPath.ts
Normal file
@ -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(<string>_value);
|
||||
}
|
||||
catch(e)
|
||||
{
|
||||
this.set_validation_error('Error! ' + _value);
|
||||
return;
|
||||
}
|
||||
const oldValue = this._value;
|
||||
this._value = <string>_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 = <string>_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`
|
||||
<div
|
||||
part="form-control"
|
||||
class=${classMap({
|
||||
'vfs-path': true,
|
||||
'vfs-path__readonly': !isEditable,
|
||||
'vfs-path__disabled': this.disabled,
|
||||
'form-control': true,
|
||||
'form-control--medium': true,
|
||||
'form-control--has-label': hasLabel,
|
||||
'form-control--has-help-text': hasHelpText
|
||||
})}
|
||||
>
|
||||
<label
|
||||
id="label"
|
||||
part="form-control-label"
|
||||
class="form-control__label"
|
||||
aria-hidden=${hasLabel ? 'false' : 'true'}
|
||||
@click=${this.handleLabelClick}
|
||||
>
|
||||
<slot name="label">${this.label}</slot>
|
||||
</label>
|
||||
<div part="form-control-input" class="form-control-input"
|
||||
@click=${() => this.focus()}
|
||||
>
|
||||
<slot part="prefix" name="prefix"></slot>
|
||||
${editing ? html`
|
||||
<input
|
||||
class="vfs-path__value-input"
|
||||
type="text"
|
||||
?disabled=${this.disabled}
|
||||
?required=${this.required}
|
||||
.value=${this.value}
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
@blur=${() => this.blur()}
|
||||
@keydown=${this.handleKeyDown}
|
||||
/>` : html`
|
||||
<sl-breadcrumb
|
||||
class="vfs-path__breadcrumb"
|
||||
@click=${this.handlePathClick}
|
||||
>
|
||||
<span slot="separator">/</span>
|
||||
${repeat(pathParts, (path) =>
|
||||
{
|
||||
return html`
|
||||
<sl-breadcrumb-item class="vfs-path__directory">${path}</sl-breadcrumb-item>`;
|
||||
})}
|
||||
</sl-breadcrumb>
|
||||
${!isEditable ? nothing : html`
|
||||
<button
|
||||
part="edit-button"
|
||||
class="vfs-path__edit"
|
||||
type="button"
|
||||
aria-label=${this.egw().lang('edit')}
|
||||
@mousedown=${this.handleEditMouseDown}
|
||||
tabindex="-1"
|
||||
>
|
||||
<slot name="edit-icon">
|
||||
<sl-icon name="pencil"></sl-icon>
|
||||
</slot>
|
||||
</button>`
|
||||
}
|
||||
`}
|
||||
<slot part="suffix" name="suffix"></slot>
|
||||
</div>
|
||||
<div
|
||||
part="form-control-help-text"
|
||||
id="help-text"
|
||||
class="form-control__help-text"
|
||||
aria-hidden=${hasHelpText ? 'false' : 'true'}
|
||||
>
|
||||
<slot name="help-text">${this.helpText}</slot>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("et2-vfs-path", Et2VfsPath);
|
86
api/js/etemplate/Et2Vfs/test/Et2VfsPath.test.ts
Normal file
86
api/js/etemplate/Et2Vfs/test/Et2VfsPath.test.ts
Normal file
@ -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<Et2VfsPath>(html`
|
||||
<et2-vfs-path label="I'm a vfs path">
|
||||
</et2-vfs-path>
|
||||
`);
|
||||
|
||||
// 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");
|
@ -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";
|
||||
|
Loading…
Reference in New Issue
Block a user