From 05f669a7bdd983c37f2967cc404960707f3e8186 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Mon, 8 Sep 2025 19:21:37 +0000 Subject: [PATCH] feat(dees-input-list): add new input list component with demo and validation features --- ts_web/elements/dees-input-list.demo.ts | 275 +++++++++++ ts_web/elements/dees-input-list.ts | 614 ++++++++++++++++++++++++ ts_web/elements/index.ts | 1 + 3 files changed, 890 insertions(+) create mode 100644 ts_web/elements/dees-input-list.demo.ts create mode 100644 ts_web/elements/dees-input-list.ts diff --git a/ts_web/elements/dees-input-list.demo.ts b/ts_web/elements/dees-input-list.demo.ts new file mode 100644 index 0000000..3c4407b --- /dev/null +++ b/ts_web/elements/dees-input-list.demo.ts @@ -0,0 +1,275 @@ +import { html, css } from '@design.estate/dees-element'; +import '@design.estate/dees-wcctools/demotools'; +import './dees-panel.js'; +import './dees-form.js'; +import './dees-input-text.js'; +import './dees-form-submit.js'; + +export const demoFunc = () => html` + + + +
+ + +
+ 💡 Double-click any item to quickly edit it inline +
+
+ + + +
+ 🔄 Drag the grip handle to reorder tasks by priority +
+
+ + +
+ + + +
+
+ + + + + + + + + + + + + +
+ + + +
+ + + + +
+
+ + + { + const preview = document.querySelector('#list-json'); + if (preview) { + const data = { + items: e.detail.value, + count: e.detail.value.length, + timestamp: new Date().toISOString() + }; + preview.textContent = JSON.stringify(data, null, 2); + } + }} + > + +
+ { + "items": [], + "count": 0, + "timestamp": "${new Date().toISOString()}" + } +
+ +
+ ✨ Add, edit, remove, and reorder items to see the JSON output update in real-time +
+
+ + + + + + + + +
+
+`; \ No newline at end of file diff --git a/ts_web/elements/dees-input-list.ts b/ts_web/elements/dees-input-list.ts new file mode 100644 index 0000000..89aa077 --- /dev/null +++ b/ts_web/elements/dees-input-list.ts @@ -0,0 +1,614 @@ +import { + customElement, + html, + css, + cssManager, + property, + state, + type TemplateResult, +} from '@design.estate/dees-element'; +import { DeesInputBase } from './dees-input-base.js'; +import './dees-icon.js'; +import './dees-button.js'; +import { demoFunc } from './dees-input-list.demo.js'; + +declare global { + interface HTMLElementTagNameMap { + 'dees-input-list': DeesInputList; + } +} + +@customElement('dees-input-list') +export class DeesInputList extends DeesInputBase { + // STATIC + public static demo = demoFunc; + + // INSTANCE + @property({ type: Array }) + public value: string[] = []; + + @property({ type: String }) + public placeholder: string = 'Add new item...'; + + @property({ type: Number }) + public maxItems: number = 0; // 0 means unlimited + + @property({ type: Number }) + public minItems: number = 0; + + @property({ type: Boolean }) + public allowDuplicates: boolean = false; + + @property({ type: Boolean }) + public sortable: boolean = false; + + @property({ type: Boolean }) + public confirmDelete: boolean = false; + + @property({ type: String }) + public validationText: string = ''; + + @state() + private inputValue: string = ''; + + @state() + private editingIndex: number = -1; + + @state() + private editingValue: string = ''; + + @state() + private draggedIndex: number = -1; + + @state() + private dragOverIndex: number = -1; + + public static styles = [ + ...DeesInputBase.baseStyles, + cssManager.defaultStyles, + css` + :host { + display: block; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; + } + + .input-wrapper { + width: 100%; + } + + .list-container { + background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')}; + border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; + border-radius: 6px; + overflow: hidden; + transition: all 0.15s ease; + } + + .list-container:hover:not(.disabled) { + border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')}; + } + + .list-container:focus-within { + border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')}; + box-shadow: 0 0 0 3px ${cssManager.bdTheme('hsl(222.2 47.4% 51.2% / 0.1)', 'hsl(217.2 91.2% 59.8% / 0.1)')}; + } + + .list-container.disabled { + opacity: 0.6; + cursor: not-allowed; + } + + .list-items { + max-height: 400px; + overflow-y: auto; + } + + .list-item { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; + background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')}; + transition: all 0.15s ease; + position: relative; + } + + .list-item:last-of-type { + border-bottom: none; + } + + .list-item:hover:not(.disabled) { + background: ${cssManager.bdTheme('hsl(0 0% 97.5%)', 'hsl(0 0% 6.9%)')}; + } + + .list-item.dragging { + opacity: 0.4; + background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 10.8%)')}; + } + + .list-item.drag-over { + background: ${cssManager.bdTheme('hsl(210 40% 93.1%)', 'hsl(215 20.2% 13.8%)')}; + border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')}; + } + + .drag-handle { + display: flex; + align-items: center; + cursor: move; + color: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')}; + transition: color 0.15s ease; + } + + .drag-handle:hover { + color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')}; + } + + .drag-handle dees-icon { + width: 16px; + height: 16px; + } + + .item-content { + flex: 1; + display: flex; + align-items: center; + min-width: 0; + } + + .item-text { + flex: 1; + color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')}; + font-size: 14px; + line-height: 20px; + word-break: break-word; + } + + .item-edit-input { + flex: 1; + padding: 4px 8px; + font-size: 14px; + font-family: inherit; + background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')}; + border: 1px solid ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')}; + border-radius: 4px; + outline: none; + color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')}; + } + + .item-actions { + display: flex; + gap: 4px; + align-items: center; + } + + .action-button { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 4px; + background: transparent; + border: none; + cursor: pointer; + transition: all 0.15s ease; + color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')}; + } + + .action-button:hover { + background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')}; + color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')}; + } + + .action-button.save { + color: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3%)', 'hsl(142.1 70.6% 45.3%)')}; + } + + .action-button.save:hover { + background: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3% / 0.1)', 'hsl(142.1 70.6% 45.3% / 0.1)')}; + } + + .action-button.cancel { + color: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 50.6%)')}; + } + + .action-button.cancel:hover { + background: ${cssManager.bdTheme('hsl(0 72.2% 50.6% / 0.1)', 'hsl(0 62.8% 50.6% / 0.1)')}; + } + + .action-button.delete { + color: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 50.6%)')}; + } + + .action-button.delete:hover { + background: ${cssManager.bdTheme('hsl(0 72.2% 50.6% / 0.1)', 'hsl(0 62.8% 50.6% / 0.1)')}; + } + + .action-button dees-icon { + width: 14px; + height: 14px; + } + + .add-item-container { + display: flex; + gap: 8px; + padding: 12px 16px; + background: ${cssManager.bdTheme('hsl(0 0% 97.5%)', 'hsl(0 0% 6.9%)')}; + border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; + } + + .add-input { + flex: 1; + padding: 8px 12px; + font-size: 14px; + font-family: inherit; + background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')}; + border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; + border-radius: 4px; + outline: none; + color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')}; + transition: all 0.15s ease; + } + + .add-input:focus { + border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')}; + box-shadow: 0 0 0 3px ${cssManager.bdTheme('hsl(222.2 47.4% 51.2% / 0.1)', 'hsl(217.2 91.2% 59.8% / 0.1)')}; + } + + .add-input::placeholder { + color: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')}; + } + + .add-input:disabled { + cursor: not-allowed; + opacity: 0.5; + } + + .add-button { + padding: 8px 16px; + } + + .empty-state { + padding: 32px 16px; + text-align: center; + color: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')}; + font-size: 14px; + font-style: italic; + } + + .validation-message { + color: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 30.6%)')}; + font-size: 13px; + margin-top: 6px; + line-height: 1.5; + } + + .description { + color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')}; + font-size: 13px; + margin-top: 6px; + line-height: 1.5; + } + + /* Scrollbar styling */ + .list-items::-webkit-scrollbar { + width: 8px; + } + + .list-items::-webkit-scrollbar-track { + background: transparent; + } + + .list-items::-webkit-scrollbar-thumb { + background: ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 24.9%)')}; + border-radius: 4px; + } + + .list-items::-webkit-scrollbar-thumb:hover { + background: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 34.9%)')}; + } + + /* Animation for adding/removing items */ + @keyframes slideIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + .list-item { + animation: slideIn 0.2s ease; + } + `, + ]; + + public render(): TemplateResult { + return html` +
+ ${this.label ? html`` : ''} + +
+
+ ${this.value.length > 0 ? this.value.map((item, index) => html` +
this.handleDragStart(e, index)} + @dragend=${this.handleDragEnd} + @dragover=${(e: DragEvent) => this.handleDragOver(e, index)} + @dragleave=${this.handleDragLeave} + @drop=${(e: DragEvent) => this.handleDrop(e, index)} + > + ${this.sortable && !this.disabled ? html` +
+ +
+ ` : ''} + +
+ ${this.editingIndex === index ? html` + this.editingValue = (e.target as HTMLInputElement).value} + @keydown=${(e: KeyboardEvent) => this.handleEditKeyDown(e, index)} + @blur=${() => this.saveEdit(index)} + /> + ` : html` +
!this.disabled && this.startEdit(index)}> + ${item} +
+ `} +
+ +
+ ${this.editingIndex === index ? html` + + + ` : html` + ${!this.disabled ? html` + + + ` : ''} + `} +
+
+ `) : html` +
+ No items added yet +
+ `} +
+ + ${!this.disabled && (!this.maxItems || this.value.length < this.maxItems) ? html` +
+ + + Add + +
+ ` : ''} +
+ + ${this.validationText ? html` +
${this.validationText}
+ ` : ''} + + ${this.description ? html` +
${this.description}
+ ` : ''} +
+ `; + } + + private handleInput(e: InputEvent) { + this.inputValue = (e.target as HTMLInputElement).value; + } + + private handleAddKeyDown(e: KeyboardEvent) { + if (e.key === 'Enter' && this.inputValue.trim()) { + e.preventDefault(); + this.addItem(); + } + } + + private handleEditKeyDown(e: KeyboardEvent, index: number) { + if (e.key === 'Enter') { + e.preventDefault(); + this.saveEdit(index); + } else if (e.key === 'Escape') { + e.preventDefault(); + this.cancelEdit(); + } + } + + private addItem() { + const trimmedValue = this.inputValue.trim(); + if (!trimmedValue) return; + + if (!this.allowDuplicates && this.value.includes(trimmedValue)) { + this.validationText = 'This item already exists in the list'; + setTimeout(() => this.validationText = '', 3000); + return; + } + + if (this.maxItems && this.value.length >= this.maxItems) { + this.validationText = `Maximum ${this.maxItems} items allowed`; + setTimeout(() => this.validationText = '', 3000); + return; + } + + this.value = [...this.value, trimmedValue]; + this.inputValue = ''; + this.validationText = ''; + + // Clear the input + const input = this.shadowRoot?.querySelector('.add-input') as HTMLInputElement; + if (input) { + input.value = ''; + input.focus(); + } + + this.emitChange(); + } + + private startEdit(index: number) { + this.editingIndex = index; + this.editingValue = this.value[index]; + + // Focus the input after render + this.updateComplete.then(() => { + const input = this.shadowRoot?.querySelector('.item-edit-input') as HTMLInputElement; + if (input) { + input.focus(); + input.select(); + } + }); + } + + private saveEdit(index: number) { + const trimmedValue = this.editingValue.trim(); + + if (!trimmedValue) { + this.cancelEdit(); + return; + } + + if (!this.allowDuplicates && trimmedValue !== this.value[index] && this.value.includes(trimmedValue)) { + this.validationText = 'This item already exists in the list'; + setTimeout(() => this.validationText = '', 3000); + return; + } + + const newValue = [...this.value]; + newValue[index] = trimmedValue; + this.value = newValue; + + this.editingIndex = -1; + this.editingValue = ''; + this.validationText = ''; + this.emitChange(); + } + + private cancelEdit() { + this.editingIndex = -1; + this.editingValue = ''; + } + + private async removeItem(index: number) { + if (this.confirmDelete) { + const confirmed = await this.showConfirmDialog(`Delete "${this.value[index]}"?`); + if (!confirmed) return; + } + + this.value = this.value.filter((_, i) => i !== index); + this.emitChange(); + } + + private async showConfirmDialog(message: string): Promise { + // For now, use native confirm. In production, this should use a proper modal + return confirm(message); + } + + // Drag and drop handlers + private handleDragStart(e: DragEvent, index: number) { + if (!this.sortable || this.disabled) return; + + this.draggedIndex = index; + e.dataTransfer!.effectAllowed = 'move'; + e.dataTransfer!.setData('text/plain', index.toString()); + } + + private handleDragEnd() { + this.draggedIndex = -1; + this.dragOverIndex = -1; + } + + private handleDragOver(e: DragEvent, index: number) { + if (!this.sortable || this.disabled) return; + + e.preventDefault(); + e.dataTransfer!.dropEffect = 'move'; + this.dragOverIndex = index; + } + + private handleDragLeave() { + this.dragOverIndex = -1; + } + + private handleDrop(e: DragEvent, dropIndex: number) { + if (!this.sortable || this.disabled) return; + + e.preventDefault(); + const draggedIndex = parseInt(e.dataTransfer!.getData('text/plain')); + + if (draggedIndex !== dropIndex) { + const newValue = [...this.value]; + const [draggedItem] = newValue.splice(draggedIndex, 1); + newValue.splice(dropIndex, 0, draggedItem); + this.value = newValue; + this.emitChange(); + } + + this.draggedIndex = -1; + this.dragOverIndex = -1; + } + + private emitChange() { + this.dispatchEvent(new CustomEvent('change', { + detail: { value: this.value }, + bubbles: true, + composed: true + })); + this.changeSubject.next(this); + } + + public getValue(): string[] { + return this.value; + } + + public setValue(value: string[]): void { + this.value = value || []; + } + + public async validate(): Promise { + if (this.required && (!this.value || this.value.length === 0)) { + this.validationText = 'At least one item is required'; + return false; + } + + if (this.minItems && this.value.length < this.minItems) { + this.validationText = `At least ${this.minItems} items required`; + return false; + } + + this.validationText = ''; + return true; + } +} \ No newline at end of file diff --git a/ts_web/elements/index.ts b/ts_web/elements/index.ts index fe083e6..e1d35ab 100644 --- a/ts_web/elements/index.ts +++ b/ts_web/elements/index.ts @@ -32,6 +32,7 @@ export * from './dees-input-datepicker.js'; export * from './dees-input-dropdown.js'; export * from './dees-input-fileupload.js'; export * from './dees-input-iban.js'; +export * from './dees-input-list.js'; export * from './profilepicture/dees-input-profilepicture.js'; export * from './dees-input-typelist.js'; export * from './dees-input-phone.js';