wip
This commit is contained in:
213
tp-tree-nav.js
213
tp-tree-nav.js
@@ -80,6 +80,18 @@ export class TpTreeNav extends LitElement {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--tp-tree-empty-color, rgba(0,0,0,0.56));
|
||||||
|
}
|
||||||
|
|
||||||
.context-menu-overlay {
|
.context-menu-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -90,7 +102,6 @@ export class TpTreeNav extends LitElement {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
background: var(--tp-tree-menu-bg, #fff);
|
background: var(--tp-tree-menu-bg, #fff);
|
||||||
color: inherit;
|
|
||||||
border: 1px solid var(--tp-tree-menu-border, rgba(0,0,0,0.1));
|
border: 1px solid var(--tp-tree-menu-border, rgba(0,0,0,0.1));
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
box-shadow: 0 6px 18px rgba(0,0,0,0.18);
|
box-shadow: 0 6px 18px rgba(0,0,0,0.18);
|
||||||
@@ -129,6 +140,10 @@ export class TpTreeNav extends LitElement {
|
|||||||
render() {
|
render() {
|
||||||
const items = this._flattenNodes();
|
const items = this._flattenNodes();
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
return html`${this._renderEmpty()}`;
|
||||||
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="tree">
|
<div class="tree">
|
||||||
${virtualize({
|
${virtualize({
|
||||||
@@ -150,6 +165,7 @@ export class TpTreeNav extends LitElement {
|
|||||||
states: Array.isArray(node?.states) ? node.states : [],
|
states: Array.isArray(node?.states) ? node.states : [],
|
||||||
path: item.path,
|
path: item.path,
|
||||||
hasChildren,
|
hasChildren,
|
||||||
|
expanded,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (custom) {
|
if (custom) {
|
||||||
@@ -175,6 +191,21 @@ export class TpTreeNav extends LitElement {
|
|||||||
</div>
|
</div>
|
||||||
${icon ? html`<tp-icon class="icon" part="icon" .icon=${icon}></tp-icon>` : html`<span class="icon" part="icon" aria-hidden="true"></span>`}
|
${icon ? html`<tp-icon class="icon" part="icon" .icon=${icon}></tp-icon>` : html`<span class="icon" part="icon" aria-hidden="true"></span>`}
|
||||||
<div class="label" part="label">${node?.label ?? ''}</div>
|
<div class="label" part="label">${node?.label ?? ''}</div>
|
||||||
|
${this.showActions && Array.isArray(node?.actions) && node.actions.length
|
||||||
|
? html`
|
||||||
|
<div class="actions" part="actions">
|
||||||
|
${node.actions.map((action) => html`
|
||||||
|
<tp-icon
|
||||||
|
class="action-icon"
|
||||||
|
part="action"
|
||||||
|
.icon=${this._resolveIcon(action.icon)}
|
||||||
|
.tooltip=${action.tooltip}
|
||||||
|
@click=${(e) => this._onInlineAction(item, action, e)}
|
||||||
|
></tp-icon>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: null}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -201,12 +232,31 @@ export class TpTreeNav extends LitElement {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_renderEmpty() {
|
||||||
|
if (typeof this.renderEmpty === 'function') {
|
||||||
|
return this.renderEmpty();
|
||||||
|
}
|
||||||
|
return html`<div class="empty-state" part="empty">${this.emptyMessage}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
static get properties() {
|
static get properties() {
|
||||||
return {
|
return {
|
||||||
roots: { type: Array },
|
roots: { type: Array },
|
||||||
defaultActions: { type: Array },
|
defaultActions: { type: Array },
|
||||||
renderNode: { type: Function },
|
renderNode: { type: Function },
|
||||||
beforeContextMenu: { type: Function },
|
beforeContextMenu: { type: Function },
|
||||||
|
renderEmpty: { type: Function },
|
||||||
|
items: { type: Array },
|
||||||
|
manageState: { type: Boolean },
|
||||||
|
multiSelect: { type: Boolean },
|
||||||
|
expandedPaths: { type: Array },
|
||||||
|
selectedPaths: { type: Array },
|
||||||
|
selectionState: { type: String },
|
||||||
|
autoExpandNew: { type: Boolean },
|
||||||
|
applyStates: { type: Function },
|
||||||
|
emptyMessage: { type: String },
|
||||||
|
showActions: { type: Boolean },
|
||||||
|
expandOnDoubleClick: { type: Boolean },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,9 +286,24 @@ export class TpTreeNav extends LitElement {
|
|||||||
this.defaultActions = [];
|
this.defaultActions = [];
|
||||||
this.renderNode = null;
|
this.renderNode = null;
|
||||||
this.beforeContextMenu = null;
|
this.beforeContextMenu = null;
|
||||||
|
this.renderEmpty = null;
|
||||||
|
this.items = null;
|
||||||
|
this.manageState = false;
|
||||||
|
this.multiSelect = false;
|
||||||
|
this.expandedPaths = [];
|
||||||
|
this.selectedPaths = [];
|
||||||
|
this.selectionState = 'selected';
|
||||||
|
this.autoExpandNew = false;
|
||||||
|
this.applyStates = null;
|
||||||
|
this.emptyMessage = 'No items';
|
||||||
|
this.showActions = false;
|
||||||
|
this.expandOnDoubleClick = false;
|
||||||
this._contextMenu = null;
|
this._contextMenu = null;
|
||||||
this._outsideHandler = null;
|
this._outsideHandler = null;
|
||||||
this._keyHandler = null;
|
this._keyHandler = null;
|
||||||
|
this._knownPaths = new Set();
|
||||||
|
this._expandedPathSet = new Set();
|
||||||
|
this._selectedPathSet = new Set();
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
@@ -246,6 +311,18 @@ export class TpTreeNav extends LitElement {
|
|||||||
this._removeOutsideHandlers();
|
this._removeOutsideHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updated(changed) {
|
||||||
|
super.updated?.(changed);
|
||||||
|
const managed = this.manageState;
|
||||||
|
const itemsChanged = managed && changed.has('items');
|
||||||
|
const expandChanged = managed && changed.has('expandedPaths');
|
||||||
|
const selectChanged = managed && changed.has('selectedPaths');
|
||||||
|
|
||||||
|
if (managed && (itemsChanged || expandChanged || selectChanged)) {
|
||||||
|
this._rebuildManagedTree();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_flattenNodes() {
|
_flattenNodes() {
|
||||||
const results = [];
|
const results = [];
|
||||||
const roots = Array.isArray(this.roots) ? this.roots : [];
|
const roots = Array.isArray(this.roots) ? this.roots : [];
|
||||||
@@ -279,18 +356,65 @@ export class TpTreeNav extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_onRowClick(item, originalEvent) {
|
_onRowClick(item, originalEvent) {
|
||||||
const ev = new CustomEvent('node-click', {
|
const isDouble = this.expandOnDoubleClick && originalEvent?.detail === 2;
|
||||||
|
|
||||||
|
if (this.manageState) {
|
||||||
|
const pathStr = item.path.join('/');
|
||||||
|
const set = new Set(this._selectedPathSet);
|
||||||
|
|
||||||
|
if (this.multiSelect) {
|
||||||
|
if (set.has(pathStr)) {
|
||||||
|
set.delete(pathStr);
|
||||||
|
} else {
|
||||||
|
set.add(pathStr);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
set.clear();
|
||||||
|
set.add(pathStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._selectedPathSet = set;
|
||||||
|
this.selectedPaths = Array.from(set);
|
||||||
|
this._rebuildManagedTree();
|
||||||
|
|
||||||
|
this.dispatchEvent(new CustomEvent('node-selected', {
|
||||||
|
detail: { node: item.node, path: item.path, originalEvent },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDouble) {
|
||||||
|
this._toggleExpand(item, 'double-click', originalEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dispatchEvent(new CustomEvent('node-click', {
|
||||||
detail: { node: item.node, path: item.path, originalEvent },
|
detail: { node: item.node, path: item.path, originalEvent },
|
||||||
bubbles: true,
|
bubbles: true,
|
||||||
composed: true,
|
composed: true,
|
||||||
cancelable: true,
|
cancelable: true,
|
||||||
});
|
}));
|
||||||
this.dispatchEvent(ev);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_onChevronClick(item, originalEvent) {
|
_onChevronClick(item, originalEvent) {
|
||||||
originalEvent.stopPropagation();
|
originalEvent.stopPropagation();
|
||||||
this._dispatchAction('toggle', item, 'chevron', originalEvent);
|
this._toggleExpand(item, 'chevron', originalEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
_toggleExpand(item, source, originalEvent) {
|
||||||
|
if (this.manageState) {
|
||||||
|
const pathStr = item.path.join('/');
|
||||||
|
const set = new Set(this._expandedPathSet);
|
||||||
|
if (set.has(pathStr)) {
|
||||||
|
set.delete(pathStr);
|
||||||
|
} else {
|
||||||
|
set.add(pathStr);
|
||||||
|
}
|
||||||
|
this._expandedPathSet = set;
|
||||||
|
this.expandedPaths = Array.from(set);
|
||||||
|
this._rebuildManagedTree();
|
||||||
|
}
|
||||||
|
this._dispatchAction('toggle', item, source, originalEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
async _onContextMenu(item, originalEvent) {
|
async _onContextMenu(item, originalEvent) {
|
||||||
@@ -343,6 +467,11 @@ export class TpTreeNav extends LitElement {
|
|||||||
this._closeContextMenu();
|
this._closeContextMenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_onInlineAction(item, action, originalEvent) {
|
||||||
|
originalEvent.stopPropagation();
|
||||||
|
this._dispatchAction(action?.action, item, 'inline-action', originalEvent);
|
||||||
|
}
|
||||||
|
|
||||||
_attachOutsideHandlers() {
|
_attachOutsideHandlers() {
|
||||||
if (this._outsideHandler) return;
|
if (this._outsideHandler) return;
|
||||||
this._outsideHandler = (e) => {
|
this._outsideHandler = (e) => {
|
||||||
@@ -405,6 +534,28 @@ export class TpTreeNav extends LitElement {
|
|||||||
return icon;
|
return icon;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_rebuildManagedTree() {
|
||||||
|
this._expandedPathSet = new Set(this.expandedPaths || this._expandedPathSet);
|
||||||
|
this._selectedPathSet = new Set(this.selectedPaths || this._selectedPathSet);
|
||||||
|
|
||||||
|
const { nodes, allPaths, expandedPaths, selectedPaths } = TpTreeNav.buildTree(
|
||||||
|
Array.isArray(this.items) ? this.items : [],
|
||||||
|
{
|
||||||
|
expandedPaths: this._expandedPathSet,
|
||||||
|
selectedPaths: this._selectedPathSet,
|
||||||
|
selectionState: this.selectionState,
|
||||||
|
applyStates: this.applyStates,
|
||||||
|
knownPaths: this._knownPaths,
|
||||||
|
autoExpandNew: this.autoExpandNew,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this._knownPaths = allPaths;
|
||||||
|
this._expandedPathSet = new Set([...expandedPaths].filter((p) => allPaths.has(p)));
|
||||||
|
this._selectedPathSet = new Set([...selectedPaths].filter((p) => allPaths.has(p)));
|
||||||
|
this.roots = nodes;
|
||||||
|
}
|
||||||
|
|
||||||
getNodesWithState(state) {
|
getNodesWithState(state) {
|
||||||
const matches = [];
|
const matches = [];
|
||||||
this._walkNodes(this.roots, [], (node, path) => {
|
this._walkNodes(this.roots, [], (node, path) => {
|
||||||
@@ -477,6 +628,58 @@ export class TpTreeNav extends LitElement {
|
|||||||
});
|
});
|
||||||
return { nodes: mapped, changed };
|
return { nodes: mapped, changed };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static buildTree(items = [], options = {}) {
|
||||||
|
const {
|
||||||
|
expandedPaths = new Set(),
|
||||||
|
selectedPaths = new Set(),
|
||||||
|
selectionState = 'selected',
|
||||||
|
applyStates,
|
||||||
|
knownPaths,
|
||||||
|
autoExpandNew = false,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const exp = expandedPaths instanceof Set ? new Set(expandedPaths) : new Set(expandedPaths);
|
||||||
|
const sel = selectedPaths instanceof Set ? new Set(selectedPaths) : new Set(selectedPaths);
|
||||||
|
const allPaths = new Set();
|
||||||
|
|
||||||
|
const mapNode = (node, parentPath = '') => {
|
||||||
|
const segment = node?.slug ?? '';
|
||||||
|
const fullPath = parentPath ? `${parentPath}/${segment}` : segment;
|
||||||
|
allPaths.add(fullPath);
|
||||||
|
|
||||||
|
if (autoExpandNew && knownPaths && !knownPaths.has(fullPath)) {
|
||||||
|
exp.add(fullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const states = [];
|
||||||
|
if (exp.has(fullPath)) states.push('expanded');
|
||||||
|
if (sel.has(fullPath) && selectionState) states.push(selectionState);
|
||||||
|
|
||||||
|
const extraStates = applyStates ? applyStates(node, fullPath.split('/'), states) : null;
|
||||||
|
if (Array.isArray(extraStates)) {
|
||||||
|
extraStates.forEach((s) => {
|
||||||
|
if (s && !states.includes(s)) states.push(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const children = Array.isArray(node?.children)
|
||||||
|
? node.children.map((child) => mapNode(child, fullPath))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
states,
|
||||||
|
fullPath,
|
||||||
|
children,
|
||||||
|
source: node,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const nodes = Array.isArray(items) ? items.map((n) => mapNode(n, '')) : [];
|
||||||
|
|
||||||
|
return { nodes, allPaths, expandedPaths: exp, selectedPaths: sel };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.customElements.define('tp-tree-nav', TpTreeNav);
|
window.customElements.define('tp-tree-nav', TpTreeNav);
|
||||||
|
|||||||
Reference in New Issue
Block a user