Reputation: 984
Is there a Vaadin component or add-on that provides a ComboBox with multiselect, that works like most tagging systems work? (see picture) Pretty much like Stackoverflow's tagging. It would be perfect if you could also add new tags this way.
Upvotes: 1
Views: 556
Reputation: 191
I made such a component for Vaadin 22.0.5, in Typescript. If you need a Flow component you may add a small Java wrapper around it.
Content of token-field.ts
import "@vaadin/combo-box";
import "@vaadin/icons";
import "@vaadin/custom-field";
import {customElement, state, property, query} from "lit/decorators";
import {Layout} from "Frontend/views/view";
import {css, html, PropertyValues} from "lit";
import {repeat} from "lit/directives/repeat";
import {ComboBox} from "@vaadin/combo-box";
import styles from "./token-field.css";
import {registerStyles} from "@vaadin/vaadin-themable-mixin/register-styles";
@customElement('token-field')
export class TokenField extends Layout {
private readonly focusEntered = (e: FocusEvent) => {
const tokenSelection = this.shadowRoot?.querySelector('vaadin-combo-box') as ComboBox<string> | null | undefined;
tokenSelection?.focus();
}
@property({type: Boolean, reflect: true}) required: boolean = false;
@property({type: Boolean, reflect: true}) invalid: boolean = false;
@property({type: Boolean, reflect: true}) unique: boolean = false;
@property({type: String, reflect: true}) label: string = '';
@property({type: String, reflect: true, attribute: 'helper-text'}) helperText: string = '';
@property({type: String, reflect: true, attribute: 'error-message'}) errorMessage: string = '';
@property({type: Array}) knownTokens: Array<string> = ["IT Sicherheit", "Sicherheit", "Umwelt"];
@property({type: Array}) tokens: Array<string> = ["IT Sicherheit"];
@state() private filteredTokens: Array<string> = [];
@query('vaadin-combo-box') private tokenSelectionComboBox!: ComboBox<string>;
static get styles() {
return [styles];
}
protected render(): unknown {
return html`
<vaadin-custom-field label=${this.label} helper-text=${this.helperText} error-message="${this.errorMessage}" ?required=${this.required} ?invalid=${this.invalid}>
<div class="input">
${repeat(this.tokens, (token, index) => html`
<span class="badge" theme="badge pill">
<span>${token}</span>
<vaadin-button theme="contrast tertiary-inline" title="Remove token: ${token}" @click="${() => this.tokenRemoveClicked(index)}">
<vaadin-icon icon="vaadin:close-small"></vaadin-icon>
</vaadin-button>
</span>
`)}
<vaadin-combo-box .items="${this.filteredTokens}" allow-custom-value @change=${this.tokenSelectionChanged} @custom-value-set=${this.tokenSelectionCustomValueSet} theme="small transparent"></vaadin-combo-box>
</div>
</vaadin-custom-field>
`;
}
connectedCallback() {
super.connectedCallback();
this.addEventListener('focus', this.focusEntered);
}
disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener('focus', this.focusEntered);
}
protected firstUpdated(_changedProperties: PropertyValues): void {
super.firstUpdated(_changedProperties);
this.updateFilteredTokens();
}
private tokenRemoveClicked(index: number): void {
this.tokens.splice(index, 1);
this.requestUpdate('tokens');
this.updateFilteredTokens();
}
private tokenSelectionChanged(event: Event): void {
const tokenSelection = event.currentTarget as ComboBox<string>;
const newToken = tokenSelection.value.trim();
if (this.unique) {
const index = this.tokens.indexOf(newToken);
if (index >= 0) {
this.tokens.splice(index, 1);
}
}
this.tokens.push(newToken);
this.updateFilteredTokens();
tokenSelection.value = '';
}
private tokenSelectionCustomValueSet(event: CustomEvent<string>): void {
const newToken = event.detail.trim();
if (this.knownTokens.indexOf(newToken) < 0) {
this.knownTokens.push(newToken);
}
}
private updateFilteredTokens(): void {
this.filteredTokens = this.knownTokens.filter(token => this.tokens.indexOf(token) < 0);
}
}
registerStyles(
'vaadin-combo-box',
css`
:host([theme~='transparent']) [part='input-field'] {
background-color: transparent;
}
:host([theme~='transparent'][focus-ring]) [part='input-field'] {
box-shadow: initial;
}
:host(:hover[theme~='transparent']:not([readonly]):not([focused])) [part='input-field']::after {
opacity: 0;
}
`,
{ moduleId: 'token-custom-field-styles' }
);
Content of token-field.css
.input {
min-height: var(--lumo-text-field-size, var(--lumo-size-m));
background-color: green;
border-radius: var(--lumo-border-radius-m);
background-color: var(--lumo-contrast-10pct);
padding: 0 0 0 3px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0 var(--lumo-space-xs);
position: relative;
}
.input::after {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
border-radius: inherit;
pointer-events: none;
background-color: var(--lumo-contrast-50pct);
opacity: 0;
transition: transform 0.15s, opacity 0.2s;
transform-origin: 100% 0;
}
.badge {
margin-top: 4px;
margin-bottom: 4px;
}
.badge vaadin-button {
margin-inline-start: var(--lumo-space-xs);
}
vaadin-custom-field {
width: inherit;
}
vaadin-custom-field[focus-ring] .input {
box-shadow: 0 0 0 2px var(--lumo-primary-color-50pct);
}
vaadin-custom-field[invalid] .input {
background-color: var(--lumo-error-color-10pct);
}
vaadin-custom-field[invalid] .input::after {
background-color: var(--lumo-error-color-50pct);
}
vaadin-custom-field[invalid][focus-ring] .input {
box-shadow: 0 0 0 2px var(--lumo-error-color-50pct);
}
vaadin-custom-field:hover:not([readonly]):not([focused]) .input::after {
opacity: 0.1;
}
vaadin-combo-box {
padding-top: 0;
padding-bottom: 0;
margin-top: 3px;
margin-bottom: 3px;
flex: 1 1 auto;
}
Upvotes: 2