Compare commits

...

6 Commits

Author SHA1 Message Date
5ee89b31b1 v3.61.0
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-05 09:50:35 +00:00
2d354ace55 feat(dees-input-list): allow freeform entries alongside candidate suggestions in dees-input-list 2026-04-05 09:50:35 +00:00
34f5239607 v3.60.0
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-05 09:26:21 +00:00
d17bdcbaad feat(dees-input-list): add candidate autocomplete with tab completion and payload retrieval 2026-04-05 09:26:21 +00:00
dc8a3b620b v3.59.1
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-05 00:28:37 +00:00
9b96949a76 fix(project): no changes to commit 2026-04-05 00:28:37 +00:00
5 changed files with 307 additions and 13 deletions

View File

@@ -1,5 +1,23 @@
# Changelog
## 2026-04-05 - 3.61.0 - feat(dees-input-list)
allow freeform entries alongside candidate suggestions in dees-input-list
- adds an allowFreeform option so Enter can add values that do not exactly match the candidate list
- shows check and question-mark indicators to distinguish known candidates from custom freeform items
- updates the component demo with a freeform-plus-candidates example
## 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)
no changes to commit
## 2026-04-05 - 3.59.0 - feat(input)
extract datepicker popup into a window-layer overlay and enhance the code editor modal status UI

View File

@@ -1,6 +1,6 @@
{
"name": "@design.estate/dees-catalog",
"version": "3.59.0",
"version": "3.61.0",
"private": false,
"description": "A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.",
"main": "dist_ts_web/index.js",

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@design.estate/dees-catalog',
version: '3.59.0',
version: '3.61.0',
description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
}

View File

@@ -262,7 +262,85 @@ export const demoFunc = () => html`
></dees-input-list>
</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. Freeform + Candidates'} .subtitle=${'Allow adding items not in the candidate list (shown with a question mark)'}>
<dees-input-list
.label=${'Tags'}
.placeholder=${'Type a tag... (freeform allowed)'}
.allowFreeform=${true}
.candidates=${[
{ viewKey: 'bug', payload: { color: 'red' } },
{ viewKey: 'feature', payload: { color: 'blue' } },
{ viewKey: 'docs', payload: { color: 'green' } },
{ viewKey: 'refactor', payload: { color: 'purple' } },
{ viewKey: 'performance', payload: { color: 'orange' } },
{ viewKey: 'security', payload: { color: 'red' } },
]}
.value=${['bug', 'my-custom-tag', 'feature']}
.description=${'Known tags get a checkmark, custom tags get a question mark. Tab to complete, Enter to add freeform.'}
></dees-input-list>
</dees-panel>
<dees-panel .title=${'12. Empty State'} .subtitle=${'How the component looks with no items'}>
<dees-input-list
.label=${'Your Ideas'}
.placeholder=${'Share your ideas...'}

View File

@@ -12,6 +12,11 @@ import '../../00group-utility/dees-icon/dees-icon.js';
import { demoFunc } from './dees-input-list.demo.js';
import { themeDefaultStyles } from '../../00theme.js';
export interface IListCandidate {
viewKey: string;
payload?: any;
}
declare global {
interface HTMLElementTagNameMap {
'dees-input-list': DeesInputList;
@@ -46,12 +51,27 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
@property({ type: Boolean })
accessor confirmDelete: boolean = false;
@property({ type: Array })
accessor candidates: IListCandidate[] = [];
@property({ type: Boolean })
accessor allowFreeform: boolean = false;
@property({ type: String })
accessor validationText: string = '';
private addedCandidatesMap: Map<string, IListCandidate> = new Map();
private matchingCandidates: IListCandidate[] = [];
@state()
accessor inputValue: string = '';
@state()
accessor ghostText: string = '';
@state()
accessor currentCandidateIndex: number = -1;
@state()
accessor editingIndex: number = -1;
@@ -167,6 +187,20 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
}
.candidate-check {
width: 14px;
height: 14px;
color: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3%)', 'hsl(142.1 70.6% 45.3%)')};
flex-shrink: 0;
}
.candidate-unknown {
width: 14px;
height: 14px;
color: ${cssManager.bdTheme('hsl(45 93% 47%)', 'hsl(45 93% 58%)')};
flex-shrink: 0;
}
.drag-handle {
display: flex;
align-items: center;
@@ -274,7 +308,7 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
}
.add-input {
flex: 1;
width: 100%;
padding: 4px 8px;
font-size: 13px;
line-height: 18px;
@@ -368,6 +402,38 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
.list-items.dropping .list-item {
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;
}
`,
];
@@ -393,6 +459,14 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
</div>
` : ''}
${this.candidates.length > 0 ? html`
${this.candidates.some(c => c.viewKey === item) ? html`
<dees-icon class="candidate-check" .icon=${'lucide:check'}></dees-icon>
` : html`
<dees-icon class="candidate-unknown" .icon=${'lucide:helpCircle'}></dees-icon>
`}
` : ''}
<div class="item-content">
${this.editingIndex === index ? html`
<input
@@ -439,15 +513,22 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
${!this.disabled && (!this.maxItems || this.value.length < this.maxItems) ? html`
<div class="add-item-container">
<input
type="text"
class="add-input"
.placeholder=${this.placeholder}
.value=${this.inputValue}
@input=${this.handleInput}
@keydown=${this.handleAddKeyDown}
?disabled=${this.disabled}
/>
<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
type="text"
class="add-input"
.placeholder=${this.placeholder}
.value=${this.inputValue}
@input=${this.handleInput}
@keydown=${this.handleAddKeyDown}
?disabled=${this.disabled}
/>
</div>
<button
class="add-button"
@click=${this.addItem}
@@ -472,11 +553,82 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
private handleInput(e: InputEvent) {
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) {
// 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()) {
e.preventDefault();
if (this.candidates.length > 0) {
// Try exact candidate match first
const match = this.candidates.find(
c => c.viewKey.toLowerCase() === this.inputValue.trim().toLowerCase()
);
if (match) {
this.selectCandidate(match);
} else if (this.allowFreeform) {
// Allow freeform entry (won't have a candidate checkmark)
this.ghostText = '';
this.currentCandidateIndex = -1;
this.matchingCandidates = [];
this.addItem();
}
return;
}
this.addItem();
}
}
@@ -491,6 +643,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() {
const trimmedValue = this.inputValue.trim();
if (!trimmedValue) return;
@@ -570,6 +766,8 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
if (!confirmed) return;
}
const removedKey = this.value[index];
this.addedCandidatesMap.delete(removedKey);
this.value = this.value.filter((_, i) => i !== index);
this.emitChange();
}