update dees-tags

This commit is contained in:
Juergen Kunz
2025-06-26 19:00:15 +00:00
parent b5a2bd7436
commit 1dbbac450c
5 changed files with 722 additions and 0 deletions

View File

@ -0,0 +1,248 @@
import { html, css } from '@design.estate/dees-element';
import '@design.estate/dees-wcctools/demotools';
import './dees-panel.js';
export const demoFunc = () => html`
<dees-demowrapper>
<style>
${css`
.demo-container {
display: flex;
flex-direction: column;
gap: 24px;
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
dees-panel {
margin-bottom: 24px;
}
dees-panel:last-child {
margin-bottom: 0;
}
.grid-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
@media (max-width: 768px) {
.grid-layout {
grid-template-columns: 1fr;
}
}
.output-preview {
margin-top: 16px;
padding: 16px;
background: #f3f4f6;
border-radius: 4px;
font-size: 12px;
color: #374151;
word-break: break-all;
max-height: 200px;
overflow-y: auto;
}
@media (prefers-color-scheme: dark) {
.output-preview {
background: #2c2c2c;
color: #e4e4e7;
}
}
.tag-preview {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px;
background: #f9fafb;
border-radius: 4px;
min-height: 40px;
align-items: center;
}
@media (prefers-color-scheme: dark) {
.tag-preview {
background: #1f2937;
}
}
.tag-preview-item {
display: inline-block;
padding: 4px 12px;
background: #e0e7ff;
color: #4338ca;
border-radius: 12px;
font-size: 14px;
}
@media (prefers-color-scheme: dark) {
.tag-preview-item {
background: #312e81;
color: #c7d2fe;
}
}
`}
</style>
<div class="demo-container">
<dees-panel .title=${'1. Basic Tags Input'} .subtitle=${'Simple tag input with common programming languages'}>
<dees-input-tags
.label=${'Programming Languages'}
.placeholder=${'Add a language...'}
.value=${['JavaScript', 'TypeScript', 'Python', 'Go']}
.description=${'Press Enter or comma to add tags'}
></dees-input-tags>
</dees-panel>
<dees-panel .title=${'2. Tags with Suggestions'} .subtitle=${'Auto-complete suggestions for faster input'}>
<dees-input-tags
.label=${'Tech Stack'}
.placeholder=${'Type to see suggestions...'}
.suggestions=${[
'React', 'Vue', 'Angular', 'Svelte', 'Lit', 'Next.js', 'Nuxt', 'SvelteKit',
'Node.js', 'Deno', 'Bun', 'Express', 'Fastify', 'Nest.js', 'Koa',
'MongoDB', 'PostgreSQL', 'Redis', 'MySQL', 'SQLite', 'Cassandra',
'Docker', 'Kubernetes', 'AWS', 'Azure', 'GCP', 'Vercel', 'Netlify'
]}
.value=${['React', 'Node.js', 'PostgreSQL', 'Docker']}
.description=${'Start typing to see suggestions from popular technologies'}
></dees-input-tags>
</dees-panel>
<dees-panel .title=${'3. Limited Tags'} .subtitle=${'Restrict the number of tags users can add'}>
<div class="grid-layout">
<dees-input-tags
.label=${'Top 3 Skills'}
.placeholder=${'Add up to 3 skills...'}
.maxTags=${3}
.value=${['Design', 'Development']}
.description=${'Maximum 3 tags allowed'}
></dees-input-tags>
<dees-input-tags
.label=${'Categories (Max 5)'}
.placeholder=${'Select categories...'}
.maxTags=${5}
.suggestions=${['Blog', 'Tutorial', 'News', 'Review', 'Guide', 'Case Study', 'Interview']}
.value=${['Tutorial', 'Guide']}
.description=${'Choose up to 5 categories'}
></dees-input-tags>
</div>
</dees-panel>
<dees-panel .title=${'4. Required & Validation'} .subtitle=${'Tags input with validation requirements'}>
<dees-input-tags
.label=${'Project Tags'}
.placeholder=${'Add at least one tag...'}
.required=${true}
.description=${'This field is required - add at least one tag'}
></dees-input-tags>
</dees-panel>
<dees-panel .title=${'5. Disabled State'} .subtitle=${'Read-only tags display'}>
<dees-input-tags
.label=${'System Tags'}
.value=${['System', 'Protected', 'Read-Only', 'Archive']}
.disabled=${true}
.description=${'These tags cannot be modified'}
></dees-input-tags>
</dees-panel>
<dees-panel .title=${'6. Form Integration'} .subtitle=${'Tags input working within a form context'}>
<dees-form>
<dees-input-text
.label=${'Project Name'}
.placeholder=${'My Awesome Project'}
.required=${true}
.key=${'name'}
></dees-input-text>
<div class="grid-layout">
<dees-input-tags
.label=${'Technologies Used'}
.placeholder=${'Add technologies...'}
.required=${true}
.key=${'technologies'}
.suggestions=${[
'TypeScript', 'JavaScript', 'Python', 'Go', 'Rust',
'React', 'Vue', 'Angular', 'Svelte',
'Node.js', 'Deno', 'Express', 'FastAPI'
]}
></dees-input-tags>
<dees-input-tags
.label=${'Project Tags'}
.placeholder=${'Add descriptive tags...'}
.key=${'tags'}
.maxTags=${10}
.suggestions=${[
'frontend', 'backend', 'fullstack', 'mobile', 'desktop',
'web', 'api', 'database', 'devops', 'ui/ux',
'opensource', 'saas', 'enterprise', 'startup'
]}
></dees-input-tags>
</div>
<dees-input-text
.label=${'Description'}
.inputType=${'textarea'}
.placeholder=${'Describe your project...'}
.key=${'description'}
></dees-input-text>
<dees-form-submit .text=${'Create Project'}></dees-form-submit>
</dees-form>
</dees-panel>
<dees-panel .title=${'7. Interactive Demo'} .subtitle=${'Add tags and see them collected in real-time'}>
<dees-input-tags
id="interactive-tags"
.label=${'Your Interests'}
.placeholder=${'Type your interests...'}
.suggestions=${[
'Music', 'Movies', 'Books', 'Travel', 'Photography',
'Cooking', 'Gaming', 'Sports', 'Art', 'Technology',
'Fashion', 'Fitness', 'Nature', 'Science', 'History'
]}
@change=${(e: CustomEvent) => {
const preview = document.querySelector('#tags-preview');
const tags = e.detail.value;
if (preview) {
if (tags.length === 0) {
preview.innerHTML = '<em style="color: #999;">No tags added yet...</em>';
} else {
preview.innerHTML = tags.map((tag: string) =>
`<span class="tag-preview-item">${tag}</span>`
).join('');
}
}
}}
></dees-input-tags>
<div class="tag-preview" id="tags-preview">
<em style="color: #999;">No tags added yet...</em>
</div>
<div class="output-preview" id="tags-json">
<em>JSON output will appear here...</em>
</div>
<script>
// Update JSON preview
const tagsInput = document.querySelector('#interactive-tags');
tagsInput?.addEventListener('change', (e) => {
const jsonPreview = document.querySelector('#tags-json');
if (jsonPreview) {
jsonPreview.textContent = JSON.stringify(e.detail.value, null, 2);
}
});
</script>
</dees-panel>
</div>
</dees-demowrapper>
`;

View File

@ -0,0 +1,401 @@
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 { demoFunc } from './dees-input-tags.demo.js';
declare global {
interface HTMLElementTagNameMap {
'dees-input-tags': DeesInputTags;
}
}
@customElement('dees-input-tags')
export class DeesInputTags extends DeesInputBase<DeesInputTags> {
// STATIC
public static demo = demoFunc;
// INSTANCE
@property({ type: Array })
public value: string[] = [];
@property({ type: String })
public placeholder: string = 'Add tags...';
@property({ type: Number })
public maxTags: number = 0; // 0 means unlimited
@property({ type: Array })
public suggestions: string[] = [];
@state()
private inputValue: string = '';
@state()
private showSuggestions: boolean = false;
@state()
private highlightedSuggestionIndex: number = -1;
@property({ type: String })
public validationText: string = '';
public static styles = [
...DeesInputBase.baseStyles,
cssManager.defaultStyles,
css`
:host {
display: block;
font-family: 'Geist Sans', sans-serif;
}
.input-wrapper {
width: 100%;
}
.tags-container {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 8px;
min-height: 40px;
background: ${cssManager.bdTheme('#fafafa', '#222222')};
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333333')};
border-radius: 8px;
transition: all 0.2s ease;
cursor: text;
}
.tags-container:focus-within {
border-color: ${cssManager.bdTheme('#0069f2', '#0084ff')};
box-shadow: 0 0 0 2px ${cssManager.bdTheme('rgba(0, 105, 242, 0.1)', 'rgba(0, 132, 255, 0.2)')};
}
.tags-container.disabled {
background: ${cssManager.bdTheme('#f5f5f5', '#1a1a1a')};
cursor: not-allowed;
opacity: 0.6;
}
.tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: ${cssManager.bdTheme('#e3f2fd', '#1e3a5f')};
color: ${cssManager.bdTheme('#1976d2', '#90caf9')};
border-radius: 16px;
font-size: 14px;
line-height: 1.2;
user-select: none;
animation: tagAppear 0.2s ease;
}
@keyframes tagAppear {
from {
transform: scale(0.8);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
.tag-remove {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s ease;
margin-left: 2px;
}
.tag-remove:hover {
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')};
}
.tag-remove dees-icon {
width: 12px;
height: 12px;
}
.tag-input {
flex: 1;
min-width: 120px;
border: none;
background: transparent;
outline: none;
font-size: 14px;
font-family: inherit;
color: ${cssManager.bdTheme('#333', '#fff')};
padding: 4px;
}
.tag-input::placeholder {
color: ${cssManager.bdTheme('#999', '#666')};
}
.tag-input:disabled {
cursor: not-allowed;
}
/* Suggestions dropdown */
.suggestions-container {
position: relative;
}
.suggestions-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 4px;
background: ${cssManager.bdTheme('#ffffff', '#222222')};
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333333')};
border-radius: 8px;
box-shadow: 0 4px 12px ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(0, 0, 0, 0.3)')};
max-height: 200px;
overflow-y: auto;
z-index: 1000;
}
.suggestion {
padding: 8px 12px;
cursor: pointer;
transition: all 0.2s ease;
color: ${cssManager.bdTheme('#333', '#fff')};
}
.suggestion:hover,
.suggestion.highlighted {
background: ${cssManager.bdTheme('#f5f5f5', '#333333')};
}
.suggestion.highlighted {
background: ${cssManager.bdTheme('#e3f2fd', '#1e3a5f')};
}
/* Validation styles */
.validation-message {
color: #d32f2f;
font-size: 12px;
margin-top: 4px;
min-height: 16px;
}
/* Description styles */
.description {
color: ${cssManager.bdTheme('#666', '#999')};
font-size: 12px;
margin-top: 4px;
}
`,
];
public render(): TemplateResult {
const filteredSuggestions = this.suggestions.filter(
suggestion =>
!this.value.includes(suggestion) &&
suggestion.toLowerCase().includes(this.inputValue.toLowerCase())
);
return html`
<div class="input-wrapper">
${this.label ? html`<dees-label .label=${this.label} .required=${this.required}></dees-label>` : ''}
<div class="suggestions-container">
<div
class="tags-container ${this.disabled ? 'disabled' : ''}"
@click=${this.handleContainerClick}
>
${this.value.map(tag => html`
<div class="tag">
<span>${tag}</span>
${!this.disabled ? html`
<div class="tag-remove" @click=${(e: Event) => this.removeTag(e, tag)}>
<dees-icon .icon=${'lucide:x'}></dees-icon>
</div>
` : ''}
</div>
`)}
${!this.disabled && (!this.maxTags || this.value.length < this.maxTags) ? html`
<input
type="text"
class="tag-input"
.placeholder=${this.placeholder}
.value=${this.inputValue}
@input=${this.handleInput}
@keydown=${this.handleKeyDown}
@focus=${this.handleFocus}
@blur=${this.handleBlur}
?disabled=${this.disabled}
/>
` : ''}
</div>
${this.showSuggestions && filteredSuggestions.length > 0 ? html`
<div class="suggestions-dropdown">
${filteredSuggestions.map((suggestion, index) => html`
<div
class="suggestion ${index === this.highlightedSuggestionIndex ? 'highlighted' : ''}"
@mousedown=${(e: Event) => {
e.preventDefault(); // Prevent blur
this.addTag(suggestion);
}}
@mouseenter=${() => this.highlightedSuggestionIndex = index}
>
${suggestion}
</div>
`)}
</div>
` : ''}
</div>
${this.validationText ? html`
<div class="validation-message">${this.validationText}</div>
` : ''}
${this.description ? html`
<div class="description">${this.description}</div>
` : ''}
</div>
`;
}
private handleContainerClick(e: Event) {
if (this.disabled) return;
const input = this.shadowRoot?.querySelector('.tag-input') as HTMLInputElement;
if (input && e.target !== input) {
input.focus();
}
}
private handleInput(e: Event) {
const input = e.target as HTMLInputElement;
this.inputValue = input.value;
// Check for comma or semicolon to add tag
if (this.inputValue.includes(',') || this.inputValue.includes(';')) {
const tag = this.inputValue.replace(/[,;]/g, '').trim();
if (tag) {
this.addTag(tag);
}
}
}
private handleKeyDown(e: KeyboardEvent) {
const input = e.target as HTMLInputElement;
if (e.key === 'Enter') {
e.preventDefault();
if (this.highlightedSuggestionIndex >= 0 && this.showSuggestions) {
const filteredSuggestions = this.suggestions.filter(
suggestion =>
!this.value.includes(suggestion) &&
suggestion.toLowerCase().includes(this.inputValue.toLowerCase())
);
if (filteredSuggestions[this.highlightedSuggestionIndex]) {
this.addTag(filteredSuggestions[this.highlightedSuggestionIndex]);
}
} else if (this.inputValue.trim()) {
this.addTag(this.inputValue.trim());
}
} else if (e.key === 'Backspace' && !this.inputValue && this.value.length > 0) {
// Remove last tag when backspace is pressed on empty input
this.removeTag(e, this.value[this.value.length - 1]);
} else if (e.key === 'ArrowDown' && this.showSuggestions) {
e.preventDefault();
const filteredCount = this.suggestions.filter(
s => !this.value.includes(s) && s.toLowerCase().includes(this.inputValue.toLowerCase())
).length;
this.highlightedSuggestionIndex = Math.min(
this.highlightedSuggestionIndex + 1,
filteredCount - 1
);
} else if (e.key === 'ArrowUp' && this.showSuggestions) {
e.preventDefault();
this.highlightedSuggestionIndex = Math.max(this.highlightedSuggestionIndex - 1, 0);
} else if (e.key === 'Escape') {
this.showSuggestions = false;
this.highlightedSuggestionIndex = -1;
}
}
private handleFocus() {
if (this.suggestions.length > 0) {
this.showSuggestions = true;
}
}
private handleBlur() {
// Delay to allow click on suggestions
setTimeout(() => {
this.showSuggestions = false;
this.highlightedSuggestionIndex = -1;
}, 200);
}
private addTag(tag: string) {
if (!tag || this.value.includes(tag)) return;
if (this.maxTags && this.value.length >= this.maxTags) return;
this.value = [...this.value, tag];
this.inputValue = '';
this.showSuggestions = false;
this.highlightedSuggestionIndex = -1;
// Clear the input
const input = this.shadowRoot?.querySelector('.tag-input') as HTMLInputElement;
if (input) {
input.value = '';
}
this.emitChange();
}
private removeTag(e: Event, tag: string) {
e.stopPropagation();
this.value = this.value.filter(t => t !== tag);
this.emitChange();
}
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<boolean> {
if (this.required && (!this.value || this.value.length === 0)) {
this.validationText = 'At least one tag is required';
return false;
}
this.validationText = '';
return true;
}
}

View File

@ -37,6 +37,7 @@ export * from './dees-progressbar.js';
export * from './dees-input-quantityselector.js';
export * from './dees-input-radiogroup.js';
export * from './dees-input-richtext.js';
export * from './dees-input-tags.js';
export * from './dees-input-text.js';
export * from './dees-label.js';
export * from './dees-mobilenavigation.js';

View File

@ -377,6 +377,29 @@ export const inputShowcase = () => html`
.placeholder=${'Type and press Enter'}
></dees-input-typelist>
</dees-panel>
<dees-panel .title=${'Tags Input'} .subtitle=${'Add and manage tags with suggestions'}>
<div class="demo-grid">
<dees-input-tags
.label=${'Project Tags'}
.placeholder=${'Add tags...'}
.value=${['frontend', 'typescript', 'webcomponents']}
.description=${'Press Enter or comma to add'}
></dees-input-tags>
<dees-input-tags
.label=${'Technologies'}
.placeholder=${'Type to see suggestions...'}
.suggestions=${[
'React', 'Vue', 'Angular', 'Svelte',
'Node.js', 'Deno', 'Express', 'MongoDB'
]}
.value=${['React', 'Node.js']}
.maxTags=${5}
.description=${'Maximum 5 tags, with suggestions'}
></dees-input-tags>
</div>
</dees-panel>
</section>
<!-- Numeric Inputs Section -->

View File

@ -8,6 +8,7 @@ import '../elements/dees-form.js';
import '../elements/dees-panel.js';
import '../elements/dees-input-text.js';
import '../elements/dees-input-radiogroup.js';
import '../elements/dees-input-tags.js';
import '../elements/dees-appui-profiledropdown.js';
export const showcasePage = () => html`
@ -512,6 +513,13 @@ export const showcasePage = () => html`
.label=${'Additional Field'}
.placeholder=${'Just to show form context'}
></dees-input-text>
<dees-input-tags
.label=${'Tags'}
.placeholder=${'Add tags...'}
.suggestions=${['urgent', 'bug', 'feature', 'documentation', 'testing']}
.description=${'Add relevant tags'}
></dees-input-tags>
</dees-form>
<p style="margin-top: 16px; color: ${cssManager.bdTheme('#666', '#999')}">
You can also right-click anywhere in this modal to test context menus.
@ -642,6 +650,47 @@ export const showcasePage = () => html`
}}>Show Multiple Toasts</dees-button>
</div>
<div class="demo-card">
<h4>Modal with Tags Input</h4>
<dees-button @click=${async () => {
await DeesModal.createAndShow({
heading: 'Tags Input Test',
width: 'medium',
content: html`
<p>Test the tags input component in a modal:</p>
<dees-form>
<dees-input-tags
.label=${'Search Terms'}
.placeholder=${'Enter search terms...'}
.value=${['typescript', 'modal']}
.suggestions=${[
'javascript', 'typescript', 'css', 'html',
'react', 'vue', 'angular', 'svelte',
'modal', 'dropdown', 'form', 'input'
]}
.description=${'Add search terms to filter results'}
></dees-input-tags>
<dees-input-tags
.label=${'Categories'}
.placeholder=${'Add categories...'}
.required=${true}
.maxTags=${3}
.description=${'Select up to 3 categories'}
></dees-input-tags>
</dees-form>
`,
menuOptions: [
{ name: 'Cancel', action: async (modal) => modal.destroy() },
{ name: 'Apply', action: async (modal) => {
DeesToast.createAndShow({ message: 'Tags applied!', type: 'success' });
modal.destroy();
}}
]
});
}}>Test Tags in Modal</dees-button>
</div>
<div class="demo-card">
<h4>Fullscreen Modal</h4>
<dees-button @click=${async () => {