import { LitElement, css, html, nothing, type TemplateResult } from "lit";
|
import { customElement, property, state } from "lit/decorators.js";
|
import { repeat } from "lit/directives/repeat.js";
|
import { classMap } from "lit/directives/class-map.js";
|
import type {
|
ConfigurationEntry,
|
ConfigurationReference,
|
CustomerNumber,
|
} from "@dh-software/baukasten-types";
|
import {
|
addReferences,
|
availableEntries,
|
moveByOffset,
|
referenceKey,
|
removeByKeys,
|
reorder,
|
selectedRows,
|
} from "./picker-logic";
|
|
/** Detail of the `configurations-change` event emitted by the picker. */
|
export interface BaukastenConfigurationChangeDetail {
|
selected: ConfigurationReference[];
|
}
|
|
/**
|
* Dual-list picker for baukasten configurations.
|
*
|
* Inputs (set as PROPERTIES by the host):
|
* .available : ConfigurationEntry[] — the catalog from the permissions package
|
* .selected : ConfigurationReference[] — current chosen refs, top = index 0 = loaded first
|
*
|
* Output: a `configurations-change` CustomEvent<BaukastenConfigurationChangeDetail> on every
|
* change, carrying the new ordered ConfigurationReference[]. The host persists it.
|
*/
|
@customElement("baukasten-configuration-picker")
|
export class BaukastenConfigurationPicker extends LitElement {
|
static styles = css`
|
:host {
|
display: block;
|
container-type: inline-size;
|
container-name: bk-picker;
|
--bk-gap: 8px;
|
--bk-border: 1px solid var(--bk-border-color, #ccc);
|
--bk-highlight: var(--bk-highlight-color, #2185d0);
|
font-family: inherit;
|
color: inherit;
|
}
|
.picker {
|
display: grid;
|
grid-template-columns: 1fr auto 1fr;
|
gap: var(--bk-gap);
|
align-items: stretch;
|
}
|
.panel {
|
display: flex;
|
flex-direction: column;
|
min-width: 0;
|
}
|
.panel-title {
|
font-weight: 600;
|
margin-bottom: 4px;
|
}
|
.list {
|
list-style: none;
|
margin: 0;
|
padding: 0;
|
border: var(--bk-border);
|
border-radius: 4px;
|
min-height: 180px;
|
max-height: 320px;
|
overflow-y: auto;
|
background: var(--bk-list-bg, #fff);
|
}
|
.item {
|
display: flex;
|
align-items: center;
|
gap: 6px;
|
padding: 6px 8px;
|
cursor: pointer;
|
border-bottom: 1px solid var(--bk-row-sep, #eee);
|
user-select: none;
|
}
|
.item:last-child { border-bottom: none; }
|
.item.highlighted { background: color-mix(in srgb, var(--bk-highlight) 18%, transparent); }
|
.item.dragging { opacity: 0.5; }
|
.item.unavailable { color: var(--bk-unavailable, #b00); font-style: italic; }
|
.item .name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.item .source { flex: 0 0 auto; font-size: 0.8em; opacity: 0.6; }
|
.item .grip { flex: 0 0 auto; cursor: grab; opacity: 0.5; }
|
.item .order { flex: 0 0 auto; display: inline-flex; gap: 2px; }
|
.empty { padding: 8px; opacity: 0.5; text-align: center; }
|
.controls {
|
display: flex;
|
flex-direction: column;
|
justify-content: center;
|
gap: var(--bk-gap);
|
}
|
button {
|
cursor: pointer;
|
border: var(--bk-border);
|
border-radius: 4px;
|
background: var(--bk-btn-bg, #f6f6f6);
|
color: inherit;
|
padding: 4px 8px;
|
line-height: 1;
|
}
|
button:disabled { opacity: 0.4; cursor: default; }
|
.controls .arrow { font-size: 1.2em; }
|
.order .ord { padding: 2px 5px; font-size: 0.8em; }
|
.panel-title .hint { font-weight: 400; opacity: 0.6; font-size: 0.85em; }
|
.arrow-glyph { display: inline-block; line-height: 1; }
|
|
/* When the component itself is narrow (mobile, or the AUC's column), stack the two
|
lists vertically and turn the transfer arrows into a horizontal down/up row. */
|
@container bk-picker (max-width: 460px) {
|
.picker { grid-template-columns: 1fr; }
|
.controls { flex-direction: row; }
|
.controls .arrow .arrow-glyph { transform: rotate(90deg); }
|
.list { min-height: 140px; }
|
.item { flex-wrap: wrap; }
|
.item .source { flex-basis: 100%; }
|
}
|
`;
|
|
/** The full catalog the user may choose from (from the permissions package). */
|
@property({ attribute: false })
|
available: ConfigurationEntry[] = [];
|
|
/** Currently chosen configurations, in merge order (index 0 = top = loaded first). */
|
@property({ attribute: false })
|
selected: ConfigurationReference[] = [];
|
|
@state() private highlightedLeft = new Set<string>();
|
@state() private highlightedRight = new Set<string>();
|
@state() private dragIndex = -1;
|
|
private commit(next: ConfigurationReference[]): void {
|
if (next === this.selected) return;
|
this.selected = next;
|
this.dispatchEvent(
|
new CustomEvent<BaukastenConfigurationChangeDetail>("configurations-change", {
|
detail: { selected: next },
|
bubbles: true,
|
composed: true,
|
}),
|
);
|
}
|
|
private toggleLeft(key: string): void {
|
const next = new Set(this.highlightedLeft);
|
if (next.has(key)) next.delete(key);
|
else next.add(key);
|
this.highlightedLeft = next;
|
}
|
|
private toggleRight(key: string): void {
|
const next = new Set(this.highlightedRight);
|
if (next.has(key)) next.delete(key);
|
else next.add(key);
|
this.highlightedRight = next;
|
}
|
|
private addHighlighted(): void {
|
const toAdd = availableEntries(this.available, this.selected).filter((entry) =>
|
this.highlightedLeft.has(referenceKey(entry)),
|
);
|
if (toAdd.length === 0) return;
|
this.highlightedLeft = new Set();
|
this.commit(addReferences(this.selected, toAdd));
|
}
|
|
private removeHighlighted(): void {
|
if (this.highlightedRight.size === 0) return;
|
const keys = this.highlightedRight;
|
this.highlightedRight = new Set();
|
this.commit(removeByKeys(this.selected, keys));
|
}
|
|
private addOne(entry: ConfigurationEntry): void {
|
this.commit(addReferences(this.selected, [entry]));
|
}
|
|
private removeOne(reference: ConfigurationReference): void {
|
this.commit(removeByKeys(this.selected, new Set([referenceKey(reference)])));
|
}
|
|
private move(index: number, delta: number, event: Event): void {
|
event.stopPropagation();
|
this.commit(moveByOffset(this.selected, index, delta));
|
}
|
|
private onDragStart(index: number): void {
|
this.dragIndex = index;
|
}
|
private onDragOver(event: DragEvent): void {
|
event.preventDefault();
|
}
|
private onDrop(index: number): void {
|
const from = this.dragIndex;
|
this.dragIndex = -1;
|
if (from !== -1) this.commit(reorder(this.selected, from, index));
|
}
|
private onDragEnd(): void {
|
this.dragIndex = -1;
|
}
|
|
private sourceLabel(customerNumber: CustomerNumber, key: string): string {
|
return `${customerNumber} / ${key}`;
|
}
|
|
render(): TemplateResult {
|
const left = availableEntries(this.available, this.selected);
|
const right = selectedRows(this.available, this.selected);
|
return html`
|
<div class="picker">
|
<div class="panel">
|
<div class="panel-title">Verfügbar</div>
|
<ul class="list">
|
${left.length === 0 ? html`<li class="empty">—</li>` : nothing}
|
${repeat(
|
left,
|
(entry) => referenceKey(entry),
|
(entry) => {
|
const key = referenceKey(entry);
|
return html`
|
<li
|
class=${classMap({ item: true, highlighted: this.highlightedLeft.has(key) })}
|
@click=${() => this.toggleLeft(key)}
|
@dblclick=${() => this.addOne(entry)}
|
>
|
<span class="name" title=${entry.displayName}>${entry.displayName}</span>
|
<span class="source">${this.sourceLabel(entry.customerNumber, entry.key)}</span>
|
</li>
|
`;
|
},
|
)}
|
</ul>
|
</div>
|
|
<div class="controls">
|
<button
|
class="arrow"
|
title="Hinzufügen"
|
?disabled=${this.highlightedLeft.size === 0}
|
@click=${this.addHighlighted}
|
><span class="arrow-glyph">›</span></button>
|
<button
|
class="arrow"
|
title="Entfernen"
|
?disabled=${this.highlightedRight.size === 0}
|
@click=${this.removeHighlighted}
|
><span class="arrow-glyph">‹</span></button>
|
</div>
|
|
<div class="panel">
|
<div class="panel-title">Ausgewählt <span class="hint">(oben = zuerst geladen)</span></div>
|
<ul class="list">
|
${right.length === 0 ? html`<li class="empty">—</li>` : nothing}
|
${repeat(
|
right,
|
(row) => referenceKey(row.reference),
|
(row, index) => {
|
const key = referenceKey(row.reference);
|
return html`
|
<li
|
class=${classMap({
|
item: true,
|
highlighted: this.highlightedRight.has(key),
|
unavailable: !row.isAvailable,
|
dragging: this.dragIndex === index,
|
})}
|
draggable="true"
|
@click=${() => this.toggleRight(key)}
|
@dblclick=${() => this.removeOne(row.reference)}
|
@dragstart=${() => this.onDragStart(index)}
|
@dragover=${this.onDragOver}
|
@drop=${() => this.onDrop(index)}
|
@dragend=${this.onDragEnd}
|
>
|
<span class="grip" title="Ziehen zum Sortieren">⋮⋮</span>
|
<span class="name" title=${row.displayName}>
|
${row.displayName}${row.isAvailable ? nothing : html` <em>(nicht verfügbar)</em>`}
|
</span>
|
<span class="source">${this.sourceLabel(row.reference.customerNumber, row.reference.key)}</span>
|
<span class="order">
|
<button class="ord" title="Nach oben" ?disabled=${index === 0} @click=${(event: Event) => this.move(index, -1, event)}>▲</button>
|
<button class="ord" title="Nach unten" ?disabled=${index === right.length - 1} @click=${(event: Event) => this.move(index, 1, event)}>▼</button>
|
</span>
|
</li>
|
`;
|
},
|
)}
|
</ul>
|
</div>
|
</div>
|
`;
|
}
|
}
|
|
declare global {
|
interface HTMLElementTagNameMap {
|
"baukasten-configuration-picker": BaukastenConfigurationPicker;
|
}
|
}
|