2024-01-25 23:24:11 +01:00
|
|
|
|
/**
|
|
|
|
|
* 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";
|
2024-04-11 16:07:00 +02:00
|
|
|
|
import {html, LitElement, nothing, PropertyValues, TemplateResult} from "lit";
|
2024-01-25 23:24:11 +01:00
|
|
|
|
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";
|
2024-01-29 17:57:52 +01:00
|
|
|
|
import {FileInfo} from "./Et2VfsSelectDialog";
|
2024-01-25 23:24:11 +01:00
|
|
|
|
import {SlBreadcrumbItem} from "@shoelace-style/shoelace";
|
|
|
|
|
import {HasSlotController} from "../Et2Widget/slot";
|
2024-04-09 19:51:21 +02:00
|
|
|
|
import {until} from "lit/directives/until.js";
|
2024-01-25 23:24:11 +01:00
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @summary Display an editable path from the VFS
|
|
|
|
|
* @since
|
|
|
|
|
*
|
2024-02-21 17:11:45 +01:00
|
|
|
|
* @slot label - The input’s label. Alternatively, you can use the label attribute.
|
2024-01-25 23:24:11 +01:00
|
|
|
|
* @slot prefix - Before the path
|
|
|
|
|
* @slot suffix - Like prefix, but after
|
2024-02-21 17:11:45 +01:00
|
|
|
|
* @slot edit-icon - The icon that switches to editing the path as text.
|
2024-01-25 23:24:11 +01:00
|
|
|
|
* @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.
|
2024-02-21 17:11:45 +01:00
|
|
|
|
* @event {CustomEvent} click - Emitted when the user clicks on part of the path. `event.detail` contains the path.
|
2024-01-25 23:24:11 +01:00
|
|
|
|
*
|
|
|
|
|
* @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);
|
|
|
|
|
}
|
|
|
|
|
|
2024-04-11 16:07:00 +02:00
|
|
|
|
updated(changedProperties : PropertyValues)
|
|
|
|
|
{
|
|
|
|
|
super.updated(changedProperties);
|
|
|
|
|
|
|
|
|
|
this.checkPathOverflow();
|
|
|
|
|
}
|
|
|
|
|
|
2024-01-25 23:24:11 +01:00
|
|
|
|
@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();
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2024-04-11 16:07:00 +02:00
|
|
|
|
protected checkPathOverflow()
|
|
|
|
|
{
|
|
|
|
|
const wrapper = this.shadowRoot.querySelector(".vfs-path__scroll");
|
|
|
|
|
const path = wrapper?.querySelector("sl-breadcrumb");
|
|
|
|
|
const scroll = path?.shadowRoot.querySelector("nav");
|
|
|
|
|
if(!wrapper || !scroll)
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
2024-04-12 18:18:25 +02:00
|
|
|
|
wrapper.parentElement.classList.remove("vfs-path__overflow");
|
2024-04-11 16:07:00 +02:00
|
|
|
|
path.updateComplete.then(() =>
|
|
|
|
|
{
|
|
|
|
|
if(wrapper.clientWidth < scroll.scrollWidth)
|
|
|
|
|
{
|
|
|
|
|
// Too small
|
|
|
|
|
wrapper.parentElement.classList.add("vfs-path__overflow");
|
2024-04-12 18:18:25 +02:00
|
|
|
|
wrapper.scrollLeft = scroll.scrollWidth - wrapper.clientWidth;
|
2024-04-11 16:07:00 +02:00
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2024-01-25 23:24:11 +01:00
|
|
|
|
protected handleLabelClick()
|
|
|
|
|
{
|
|
|
|
|
this.edit();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected handleEditMouseDown(event : MouseEvent)
|
|
|
|
|
{
|
2024-04-09 19:51:21 +02:00
|
|
|
|
event.preventDefault();
|
|
|
|
|
event.stopPropagation();
|
2024-01-25 23:24:11 +01:00
|
|
|
|
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)
|
|
|
|
|
{
|
2024-04-05 23:14:41 +02:00
|
|
|
|
let target = event.target;
|
|
|
|
|
if(target.slot == "separator")
|
|
|
|
|
{
|
|
|
|
|
target = target.parentNode;
|
|
|
|
|
}
|
2024-04-09 19:51:21 +02:00
|
|
|
|
else if(target instanceof Image && target.parentElement.slot == "prefix")
|
|
|
|
|
{
|
|
|
|
|
// Icon
|
|
|
|
|
target = target.parentElement.parentElement;
|
|
|
|
|
}
|
2024-04-05 23:14:41 +02:00
|
|
|
|
if(target instanceof SlBreadcrumbItem && event.composedPath().includes(this))
|
2024-01-25 23:24:11 +01:00
|
|
|
|
{
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
2024-04-11 16:07:00 +02:00
|
|
|
|
const dirs = Array.from(target.parentElement.querySelectorAll('sl-breadcrumb-item')) ?? [];
|
2024-04-05 23:14:41 +02:00
|
|
|
|
let stopIndex = dirs.indexOf(target) + 1;
|
2024-02-06 22:37:30 +01:00
|
|
|
|
let newPath = dirs.slice(0, stopIndex)
|
|
|
|
|
// Strip out any extra space
|
2024-04-09 19:51:21 +02:00
|
|
|
|
.map(d => (d.dataset.value ?? "").trim().replace(/\/*$/, '').trim() + "/")
|
2024-02-06 22:37:30 +01:00
|
|
|
|
.filter(p => p);
|
|
|
|
|
if(newPath[0] !== '/')
|
|
|
|
|
{
|
|
|
|
|
// Make sure we start at /, breadcrumb parsing above might lose it
|
|
|
|
|
newPath.unshift('/');
|
|
|
|
|
}
|
2024-01-25 23:24:11 +01:00
|
|
|
|
if(!(this.disabled || this.readonly))
|
|
|
|
|
{
|
|
|
|
|
const oldValue = this.value;
|
2024-04-05 23:14:41 +02:00
|
|
|
|
this.value = newPath.join("")
|
|
|
|
|
|
2024-02-06 22:37:30 +01:00
|
|
|
|
// No trailing slash in the value
|
2024-04-05 23:14:41 +02:00
|
|
|
|
if(this.value !== "/" && this.value.endsWith("/"))
|
|
|
|
|
{
|
|
|
|
|
this.value = this.value.replace(/\/*$/, '');
|
|
|
|
|
}
|
2024-01-25 23:24:11 +01:00
|
|
|
|
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("")
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-04-10 17:47:28 +02:00
|
|
|
|
protected handleScroll(event : WheelEvent)
|
|
|
|
|
{
|
2024-04-11 16:07:00 +02:00
|
|
|
|
this.shadowRoot.querySelector(".vfs-path__scroll").scrollLeft += event.deltaY;
|
2024-04-10 17:47:28 +02:00
|
|
|
|
}
|
|
|
|
|
|
2024-04-09 19:51:21 +02:00
|
|
|
|
protected _getIcon(pathParts)
|
|
|
|
|
{
|
2024-09-17 10:49:03 +02:00
|
|
|
|
let image = this.egw().image("navbar", "filemanager");
|
2024-04-09 19:51:21 +02:00
|
|
|
|
if(pathParts.length > 2 && pathParts[1] == "apps")
|
|
|
|
|
{
|
2024-09-17 10:49:03 +02:00
|
|
|
|
const app = this.egw().app(pathParts[2], 'name') || this.egw().appByTitle(pathParts[2], 'name');
|
|
|
|
|
if (app && !(image = this.egw().image('navbar', app)))
|
|
|
|
|
{
|
|
|
|
|
image = this.egw().image('navbar', 'api');
|
|
|
|
|
}
|
2024-04-09 19:51:21 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return image;
|
|
|
|
|
}
|
|
|
|
|
|
2024-04-11 16:07:00 +02:00
|
|
|
|
protected pathPartTemplate(pathParts, path, index)
|
2024-04-09 19:51:21 +02:00
|
|
|
|
{
|
|
|
|
|
let pathName : string | TemplateResult<1> = path.trim();
|
|
|
|
|
if(pathParts.length > 1 && pathParts[1] == "apps")
|
|
|
|
|
{
|
|
|
|
|
switch(index)
|
|
|
|
|
{
|
|
|
|
|
case 1:
|
|
|
|
|
pathName = this.egw().lang("applications");
|
|
|
|
|
break;
|
|
|
|
|
case 2:
|
|
|
|
|
pathName = this.egw().lang(pathName);
|
|
|
|
|
break;
|
|
|
|
|
case 3:
|
|
|
|
|
if(!isNaN(<number><unknown>pathName))
|
|
|
|
|
{
|
|
|
|
|
pathName = html`${until(this.egw().link_title(pathParts[2], pathParts[3], true) || pathName, pathName)}`
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2024-09-17 10:49:03 +02:00
|
|
|
|
// we want / aka pathParts [''] to be displayed as /, therefore we need to add another '' to it
|
|
|
|
|
else if (pathParts.length === 1)
|
|
|
|
|
{
|
|
|
|
|
pathParts.unshift('');
|
|
|
|
|
}
|
2024-04-09 19:51:21 +02:00
|
|
|
|
return html`
|
|
|
|
|
<sl-breadcrumb-item class="vfs-path__directory" data-value="${path.trim()}">
|
|
|
|
|
${pathName}
|
|
|
|
|
<span slot="separator">/</span>
|
|
|
|
|
</sl-breadcrumb-item>`;
|
|
|
|
|
}
|
|
|
|
|
|
2024-01-25 23:24:11 +01:00
|
|
|
|
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;
|
2024-02-06 22:37:30 +01:00
|
|
|
|
// No trailing slash in the path
|
2024-04-09 19:51:21 +02:00
|
|
|
|
const pathParts = this.value
|
2024-02-06 22:37:30 +01:00
|
|
|
|
// Remove trailing /
|
|
|
|
|
.replace(/\/*$/, '')
|
|
|
|
|
.split('/');
|
2024-01-25 23:24:11 +01:00
|
|
|
|
const isEditable = !(this.disabled || this.readonly);
|
|
|
|
|
const editing = this.editing && isEditable;
|
|
|
|
|
|
2024-04-09 19:51:21 +02:00
|
|
|
|
let icon = this._getIcon(pathParts);
|
|
|
|
|
|
2024-01-25 23:24:11 +01:00
|
|
|
|
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()}
|
2024-04-11 16:07:00 +02:00
|
|
|
|
@mouseout=${(e) =>
|
|
|
|
|
{
|
|
|
|
|
if(e.target.classList.contains("form-control-input"))
|
|
|
|
|
{
|
|
|
|
|
this.checkPathOverflow();
|
|
|
|
|
}
|
|
|
|
|
}}
|
2024-01-25 23:24:11 +01:00
|
|
|
|
>
|
2024-04-09 19:51:21 +02:00
|
|
|
|
<slot part="prefix" name="prefix">
|
|
|
|
|
${icon ? html`
|
2024-09-17 10:49:03 +02:00
|
|
|
|
<et2-image src="${icon}" slot="prefix" height="1.5em"
|
2024-04-09 19:51:21 +02:00
|
|
|
|
@click=${(e) =>
|
|
|
|
|
{
|
|
|
|
|
this.setValue("/");
|
|
|
|
|
this.updateComplete.then(() => {this.dispatchEvent(new Event("change", {bubbles: true}))});
|
|
|
|
|
}}
|
|
|
|
|
></et2-image>` : nothing}
|
|
|
|
|
</slot>
|
2024-01-25 23:24:11 +01:00
|
|
|
|
${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}
|
2024-04-09 19:51:21 +02:00
|
|
|
|
/>
|
|
|
|
|
<div class="vfs-path__edit"/>` : html`
|
2024-04-11 16:07:00 +02:00
|
|
|
|
<sl-icon-button name="caret-left"
|
|
|
|
|
@click=${(e) =>
|
|
|
|
|
{
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
this.handleScroll({deltaY: -20})
|
|
|
|
|
}}></sl-icon-button>
|
|
|
|
|
<div class="vfs-path__scroll"
|
|
|
|
|
@wheel=${this.handleScroll}
|
|
|
|
|
>
|
2024-01-25 23:24:11 +01:00
|
|
|
|
<sl-breadcrumb
|
2024-04-09 19:51:21 +02:00
|
|
|
|
label=${this.label || this.egw().lang("path")}
|
2024-01-25 23:24:11 +01:00
|
|
|
|
class="vfs-path__breadcrumb"
|
|
|
|
|
@click=${this.handlePathClick}
|
|
|
|
|
>
|
|
|
|
|
<span slot="separator">/</span>
|
2024-04-11 16:07:00 +02:00
|
|
|
|
${repeat(pathParts, (part, i) => this.pathPartTemplate(pathParts, part, i))}
|
2024-01-25 23:24:11 +01:00
|
|
|
|
</sl-breadcrumb>
|
2024-04-11 16:07:00 +02:00
|
|
|
|
</div>
|
|
|
|
|
<sl-icon-button name="caret-right"
|
|
|
|
|
@click=${(e) =>
|
|
|
|
|
{
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
this.handleScroll({deltaY: 20})
|
|
|
|
|
}}></sl-icon-button>
|
2024-01-25 23:24:11 +01:00
|
|
|
|
${!isEditable ? nothing : html`
|
|
|
|
|
<button
|
|
|
|
|
part="edit-button"
|
|
|
|
|
class="vfs-path__edit"
|
|
|
|
|
type="button"
|
|
|
|
|
aria-label=${this.egw().lang('edit')}
|
2024-04-09 19:51:21 +02:00
|
|
|
|
@click=${this.handleEditMouseDown}
|
2024-01-25 23:24:11 +01:00
|
|
|
|
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>
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-09-17 10:49:03 +02:00
|
|
|
|
customElements.define("et2-vfs-path", Et2VfsPath);
|