Chris
Chris

Reputation: 984

Vaadin ComboBox Multiselect for managing tags

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.

enter image description here

Upvotes: 1

Views: 556

Answers (1)

Christian Fruth
Christian Fruth

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.

enter image description here

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

Related Questions