feat(dees-input-list): add candidate autocomplete with tab completion and payload retrieval
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-04-05 - 3.60.0 - feat(dees-input-list)
|
||||||
|
add candidate autocomplete with tab completion and payload retrieval
|
||||||
|
|
||||||
|
- Adds terminal-style inline autocomplete with ghost text, Tab accept, Shift+Tab cycling, and Escape clearing for candidate-based input.
|
||||||
|
- Introduces candidate payload support with APIs to retrieve selected candidate objects after items are added.
|
||||||
|
- Updates the dees-input-list demo with candidate selection examples for team members and technology stacks.
|
||||||
|
|
||||||
## 2026-04-05 - 3.59.1 - fix(project)
|
## 2026-04-05 - 3.59.1 - fix(project)
|
||||||
no changes to commit
|
no changes to commit
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@design.estate/dees-catalog',
|
name: '@design.estate/dees-catalog',
|
||||||
version: '3.59.1',
|
version: '3.60.0',
|
||||||
description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
|
description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -262,7 +262,67 @@ export const demoFunc = () => html`
|
|||||||
></dees-input-list>
|
></dees-input-list>
|
||||||
</dees-panel>
|
</dees-panel>
|
||||||
|
|
||||||
<dees-panel .title=${'9. Empty State'} .subtitle=${'How the component looks with no items'}>
|
<dees-panel .title=${'9. Candidates with Tab Completion'} .subtitle=${'Terminal-style autocomplete — Tab accepts, Shift+Tab cycles'}>
|
||||||
|
<div class="grid-layout">
|
||||||
|
<dees-input-list
|
||||||
|
id="candidate-list"
|
||||||
|
.label=${'Assign Team Members'}
|
||||||
|
.placeholder=${'Type a name... (Tab to complete)'}
|
||||||
|
.candidates=${[
|
||||||
|
{ viewKey: 'Alice Smith', payload: { id: 1, role: 'Engineer', department: 'Frontend' } },
|
||||||
|
{ viewKey: 'Bob Johnson', payload: { id: 2, role: 'Designer', department: 'UX' } },
|
||||||
|
{ viewKey: 'Carol Williams', payload: { id: 3, role: 'Product Manager', department: 'Product' } },
|
||||||
|
{ viewKey: 'David Brown', payload: { id: 4, role: 'Engineer', department: 'Backend' } },
|
||||||
|
{ viewKey: 'Eve Davis', payload: { id: 5, role: 'QA Engineer', department: 'Quality' } },
|
||||||
|
{ viewKey: 'Frank Miller', payload: { id: 6, role: 'DevOps', department: 'Infrastructure' } },
|
||||||
|
{ viewKey: 'Grace Wilson', payload: { id: 7, role: 'Designer', department: 'UX' } },
|
||||||
|
{ viewKey: 'Henry Moore', payload: { id: 8, role: 'Engineer', department: 'Frontend' } },
|
||||||
|
]}
|
||||||
|
.value=${['Alice Smith', 'Carol Williams']}
|
||||||
|
.maxItems=${5}
|
||||||
|
.description=${'Type to see ghost completion. Tab to accept, Shift+Tab to cycle, Enter to add.'}
|
||||||
|
@change=${(e: CustomEvent) => {
|
||||||
|
const preview = document.querySelector('#candidate-json');
|
||||||
|
if (preview) {
|
||||||
|
const list = (e.target as any);
|
||||||
|
const candidates = list.getAddedCandidates();
|
||||||
|
preview.textContent = JSON.stringify(candidates, null, 2);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
></dees-input-list>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style="font-size: 13px; font-weight: 500; margin-bottom: 8px; color: inherit;">Selected Candidates (with payloads)</div>
|
||||||
|
<div class="output-preview" id="candidate-json">[]</div>
|
||||||
|
<div class="feature-note">
|
||||||
|
Try typing "D" — ghost text shows "avid Brown". Press Shift+Tab to cycle to other D-matches. Tab accepts, Enter adds.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dees-panel>
|
||||||
|
|
||||||
|
<dees-panel .title=${'10. Technology Stack'} .subtitle=${'Larger candidate pool with Shift+Tab cycling'}>
|
||||||
|
<dees-input-list
|
||||||
|
.label=${'Select Technologies'}
|
||||||
|
.placeholder=${'Type to autocomplete...'}
|
||||||
|
.candidates=${[
|
||||||
|
{ viewKey: 'TypeScript', payload: { category: 'language' } },
|
||||||
|
{ viewKey: 'React', payload: { category: 'framework' } },
|
||||||
|
{ viewKey: 'Vue.js', payload: { category: 'framework' } },
|
||||||
|
{ viewKey: 'Angular', payload: { category: 'framework' } },
|
||||||
|
{ viewKey: 'Node.js', payload: { category: 'runtime' } },
|
||||||
|
{ viewKey: 'Deno', payload: { category: 'runtime' } },
|
||||||
|
{ viewKey: 'Docker', payload: { category: 'devops' } },
|
||||||
|
{ viewKey: 'PostgreSQL', payload: { category: 'database' } },
|
||||||
|
{ viewKey: 'MongoDB', payload: { category: 'database' } },
|
||||||
|
{ viewKey: 'Redis', payload: { category: 'database' } },
|
||||||
|
{ viewKey: 'Kubernetes', payload: { category: 'devops' } },
|
||||||
|
]}
|
||||||
|
.description=${'Try "D" — cycles through Deno/Docker. "R" — cycles through React/Redis.'}
|
||||||
|
></dees-input-list>
|
||||||
|
</dees-panel>
|
||||||
|
|
||||||
|
<dees-panel .title=${'11. Empty State'} .subtitle=${'How the component looks with no items'}>
|
||||||
<dees-input-list
|
<dees-input-list
|
||||||
.label=${'Your Ideas'}
|
.label=${'Your Ideas'}
|
||||||
.placeholder=${'Share your ideas...'}
|
.placeholder=${'Share your ideas...'}
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ import '../../00group-utility/dees-icon/dees-icon.js';
|
|||||||
import { demoFunc } from './dees-input-list.demo.js';
|
import { demoFunc } from './dees-input-list.demo.js';
|
||||||
import { themeDefaultStyles } from '../../00theme.js';
|
import { themeDefaultStyles } from '../../00theme.js';
|
||||||
|
|
||||||
|
export interface IListCandidate {
|
||||||
|
viewKey: string;
|
||||||
|
payload?: any;
|
||||||
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface HTMLElementTagNameMap {
|
interface HTMLElementTagNameMap {
|
||||||
'dees-input-list': DeesInputList;
|
'dees-input-list': DeesInputList;
|
||||||
@@ -46,12 +51,24 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
|
|||||||
@property({ type: Boolean })
|
@property({ type: Boolean })
|
||||||
accessor confirmDelete: boolean = false;
|
accessor confirmDelete: boolean = false;
|
||||||
|
|
||||||
|
@property({ type: Array })
|
||||||
|
accessor candidates: IListCandidate[] = [];
|
||||||
|
|
||||||
@property({ type: String })
|
@property({ type: String })
|
||||||
accessor validationText: string = '';
|
accessor validationText: string = '';
|
||||||
|
|
||||||
|
private addedCandidatesMap: Map<string, IListCandidate> = new Map();
|
||||||
|
private matchingCandidates: IListCandidate[] = [];
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
accessor inputValue: string = '';
|
accessor inputValue: string = '';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor ghostText: string = '';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor currentCandidateIndex: number = -1;
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
accessor editingIndex: number = -1;
|
accessor editingIndex: number = -1;
|
||||||
|
|
||||||
@@ -274,7 +291,7 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.add-input {
|
.add-input {
|
||||||
flex: 1;
|
width: 100%;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 18px;
|
line-height: 18px;
|
||||||
@@ -368,6 +385,38 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
|
|||||||
.list-items.dropping .list-item {
|
.list-items.dropping .list-item {
|
||||||
transition: none !important;
|
transition: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Terminal-style inline autocomplete ── */
|
||||||
|
.autocomplete-wrapper {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-text {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 18px;
|
||||||
|
font-family: inherit;
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-typed {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-completion {
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')};
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -439,6 +488,12 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
|
|||||||
|
|
||||||
${!this.disabled && (!this.maxItems || this.value.length < this.maxItems) ? html`
|
${!this.disabled && (!this.maxItems || this.value.length < this.maxItems) ? html`
|
||||||
<div class="add-item-container">
|
<div class="add-item-container">
|
||||||
|
<div class="autocomplete-wrapper">
|
||||||
|
${this.ghostText ? html`
|
||||||
|
<span class="ghost-text">
|
||||||
|
<span class="ghost-typed">${this.inputValue}</span><span class="ghost-completion">${this.ghostText}</span>
|
||||||
|
</span>
|
||||||
|
` : ''}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="add-input"
|
class="add-input"
|
||||||
@@ -448,6 +503,7 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
|
|||||||
@keydown=${this.handleAddKeyDown}
|
@keydown=${this.handleAddKeyDown}
|
||||||
?disabled=${this.disabled}
|
?disabled=${this.disabled}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
class="add-button"
|
class="add-button"
|
||||||
@click=${this.addItem}
|
@click=${this.addItem}
|
||||||
@@ -472,11 +528,76 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
|
|||||||
|
|
||||||
private handleInput(e: InputEvent) {
|
private handleInput(e: InputEvent) {
|
||||||
this.inputValue = (e.target as HTMLInputElement).value;
|
this.inputValue = (e.target as HTMLInputElement).value;
|
||||||
|
this.updateGhostText();
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateGhostText(): void {
|
||||||
|
if (this.candidates.length === 0 || !this.inputValue) {
|
||||||
|
this.ghostText = '';
|
||||||
|
this.currentCandidateIndex = -1;
|
||||||
|
this.matchingCandidates = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const search = this.inputValue.toLowerCase();
|
||||||
|
this.matchingCandidates = this.candidates
|
||||||
|
.filter(c => {
|
||||||
|
if (this.value.includes(c.viewKey)) return false;
|
||||||
|
return c.viewKey.toLowerCase().startsWith(search);
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.viewKey.length - b.viewKey.length);
|
||||||
|
|
||||||
|
if (this.matchingCandidates.length > 0) {
|
||||||
|
this.currentCandidateIndex = 0;
|
||||||
|
this.ghostText = this.matchingCandidates[0].viewKey.slice(this.inputValue.length);
|
||||||
|
} else {
|
||||||
|
this.currentCandidateIndex = -1;
|
||||||
|
this.ghostText = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleAddKeyDown(e: KeyboardEvent) {
|
private handleAddKeyDown(e: KeyboardEvent) {
|
||||||
|
// Tab/Shift+Tab: autocomplete handling when candidates are active
|
||||||
|
if (e.key === 'Tab' && this.candidates.length > 0 && this.inputValue) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.shiftKey && this.matchingCandidates.length > 0) {
|
||||||
|
// Shift+Tab: cycle to next candidate
|
||||||
|
this.currentCandidateIndex = (this.currentCandidateIndex + 1) % this.matchingCandidates.length;
|
||||||
|
const candidate = this.matchingCandidates[this.currentCandidateIndex];
|
||||||
|
this.ghostText = candidate.viewKey.slice(this.inputValue.length);
|
||||||
|
} else if (!e.shiftKey && this.ghostText && this.matchingCandidates.length > 0) {
|
||||||
|
// Tab: accept the completion into the input
|
||||||
|
const candidate = this.matchingCandidates[this.currentCandidateIndex];
|
||||||
|
this.inputValue = candidate.viewKey;
|
||||||
|
this.ghostText = '';
|
||||||
|
const input = this.shadowRoot?.querySelector('.add-input') as HTMLInputElement;
|
||||||
|
if (input) input.value = candidate.viewKey;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape: clear ghost text
|
||||||
|
if (e.key === 'Escape' && this.ghostText) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.ghostText = '';
|
||||||
|
this.currentCandidateIndex = -1;
|
||||||
|
this.matchingCandidates = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter: add item
|
||||||
if (e.key === 'Enter' && this.inputValue.trim()) {
|
if (e.key === 'Enter' && this.inputValue.trim()) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (this.candidates.length > 0) {
|
||||||
|
// In candidate mode, only allow exact matches
|
||||||
|
const match = this.candidates.find(
|
||||||
|
c => c.viewKey.toLowerCase() === this.inputValue.trim().toLowerCase()
|
||||||
|
);
|
||||||
|
if (match) {
|
||||||
|
this.selectCandidate(match);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.addItem();
|
this.addItem();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -491,6 +612,50 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private selectCandidate(candidate: IListCandidate): void {
|
||||||
|
if (this.maxItems && this.value.length >= this.maxItems) {
|
||||||
|
this.validationText = `Maximum ${this.maxItems} items allowed`;
|
||||||
|
setTimeout(() => this.validationText = '', 3000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.allowDuplicates && this.value.includes(candidate.viewKey)) {
|
||||||
|
this.validationText = 'This item already exists in the list';
|
||||||
|
setTimeout(() => this.validationText = '', 3000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.addedCandidatesMap.set(candidate.viewKey, candidate);
|
||||||
|
this.value = [...this.value, candidate.viewKey];
|
||||||
|
this.inputValue = '';
|
||||||
|
this.ghostText = '';
|
||||||
|
this.currentCandidateIndex = -1;
|
||||||
|
this.matchingCandidates = [];
|
||||||
|
this.validationText = '';
|
||||||
|
|
||||||
|
const input = this.shadowRoot?.querySelector('.add-input') as HTMLInputElement;
|
||||||
|
if (input) { input.value = ''; input.focus(); }
|
||||||
|
|
||||||
|
this.emitChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the full candidate object for an item by its viewKey.
|
||||||
|
* Returns undefined if the item was added as a plain string.
|
||||||
|
*/
|
||||||
|
public getCandidateForItem(viewKey: string): IListCandidate | undefined {
|
||||||
|
return this.addedCandidatesMap.get(viewKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all added candidates with their payloads.
|
||||||
|
*/
|
||||||
|
public getAddedCandidates(): IListCandidate[] {
|
||||||
|
return this.value
|
||||||
|
.map(v => this.addedCandidatesMap.get(v))
|
||||||
|
.filter((c): c is IListCandidate => c !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
private addItem() {
|
private addItem() {
|
||||||
const trimmedValue = this.inputValue.trim();
|
const trimmedValue = this.inputValue.trim();
|
||||||
if (!trimmedValue) return;
|
if (!trimmedValue) return;
|
||||||
@@ -570,6 +735,8 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
|
|||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const removedKey = this.value[index];
|
||||||
|
this.addedCandidatesMap.delete(removedKey);
|
||||||
this.value = this.value.filter((_, i) => i !== index);
|
this.value = this.value.filter((_, i) => i !== index);
|
||||||
this.emitChange();
|
this.emitChange();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user