Microsoft/fast-dna

View on GitHub
packages/web-components/fast-foundation/src/combobox/combobox.ts

Summary

Maintainability
F
3 days
Test Coverage
import { autoUpdate, computePosition, flip, hide, size } from "@floating-ui/dom";
import { attr, Observable, observable, Updates } from "@microsoft/fast-element";
import { limit, uniqueId } from "@microsoft/fast-web-utilities";
import type { StaticallyComposableHTML } from "../utilities/template-helpers.js";
import type { FASTListboxOption } from "../listbox-option/listbox-option.js";
import { DelegatesARIAListbox } from "../listbox/listbox.js";
import { StartEnd } from "../patterns/start-end.js";
import type { StartEndOptions } from "../patterns/start-end.js";
import { getRootActiveElement } from "../utilities/index.js";
import { applyMixins } from "../utilities/apply-mixins.js";
import { FormAssociatedCombobox } from "./combobox.form-associated.js";
import { ComboboxAutocomplete } from "./combobox.options.js";

/**
 * Combobox configuration options
 * @public
 */
export type ComboboxOptions = StartEndOptions<FASTCombobox> & {
    indicator?: StaticallyComposableHTML<FASTCombobox>;
};

/**
 * A Combobox Custom HTML Element.
 * Implements the {@link https://w3c.github.io/aria-practices/#combobox | ARIA combobox }.
 *
 * @slot start - Content which can be provided before the input
 * @slot end - Content which can be provided after the input
 * @slot control - Used to replace the input element representing the combobox
 * @slot indicator - The visual indicator representing the expanded state
 * @slot - The default slot for the options
 * @csspart control - The wrapper element containing the input area, including start and end
 * @csspart selected-value - The input element representing the selected value
 * @csspart indicator - The element wrapping the indicator slot
 * @csspart listbox - The wrapper for the listbox slotted options
 * @fires change - Fires a custom 'change' event when the value updates
 *
 * @public
 */
export class FASTCombobox extends FormAssociatedCombobox {
    /**
     * The internal value property.
     *
     * @internal
     */
    private _value: string = "";

    /**
     * The autocomplete attribute.
     *
     * @public
     * @remarks
     * HTML Attribute: autocomplete
     */
    @attr({ attribute: "autocomplete", mode: "fromView" })
    autocomplete: ComboboxAutocomplete | undefined;

    /**
     * Reference to the internal text input element.
     *
     * @internal
     */
    public control: HTMLInputElement;

    /**
     * Reference to the internal listbox element.
     *
     * @internal
     */
    public listbox: HTMLDivElement;

    /**
     * The collection of currently filtered options.
     *
     * @public
     */
    public filteredOptions: FASTListboxOption[] = [];

    /**
     * The current filter value.
     *
     * @internal
     */
    private filter: string = "";

    /**
     * Reset the element to its first selectable option when its parent form is reset.
     *
     * @internal
     */
    public formResetCallback(): void {
        super.formResetCallback();
        this.setDefaultSelectedOption();

        if (!this.firstSelectedOption) {
            this.value = this.initialValue ?? "";
            return;
        }

        this.updateValue();
    }

    /** {@inheritDoc (FormAssociated:interface).validate} */
    public validate(): void {
        super.validate(this.control);
    }

    private get isAutocompleteInline(): boolean {
        return (
            this.autocomplete === ComboboxAutocomplete.inline || this.isAutocompleteBoth
        );
    }

    private get isAutocompleteList(): boolean {
        return this.autocomplete === ComboboxAutocomplete.list || this.isAutocompleteBoth;
    }

    private get isAutocompleteBoth(): boolean {
        return this.autocomplete === ComboboxAutocomplete.both;
    }

    /**
     * The unique id for the internal listbox element.
     *
     * @internal
     */
    public listboxId: string = uniqueId("listbox-");

    /**
     * The open attribute.
     *
     * @public
     * @remarks
     * HTML Attribute: open
     */
    @attr({ attribute: "open", mode: "boolean" })
    public open: boolean = false;

    /**
     * Sets focus and synchronize ARIA attributes when the open property changes.
     *
     * @param prev - the previous open value
     * @param next - the current open value
     *
     * @internal
     */
    protected openChanged() {
        if (this.open) {
            this.ariaControls = this.listboxId;
            this.ariaExpanded = "true";

            Updates.enqueue(() => this.setPositioning());
            this.focusAndScrollOptionIntoView();

            // focus is directed to the element when `open` is changed programmatically
            Updates.enqueue(() => this.focus());

            return;
        }

        this.ariaControls = "";
        this.ariaExpanded = "false";
    }

    /**
     * The list of options.
     *
     * @public
     * @remarks
     * Overrides `Listbox.options`.
     */
    public get options(): FASTListboxOption[] {
        Observable.track(this, "options");
        return this.filteredOptions.length ? this.filteredOptions : this._options;
    }

    public set options(value: FASTListboxOption[]) {
        this._options = value;
        Observable.notify(this, "options");
    }

    /**
     * Sets the placeholder value of the element, generally used to provide a hint to the user.
     * @public
     * @remarks
     * HTML Attribute: placeholder
     * Using this attribute is not a valid substitute for a labeling element.
     */
    @attr
    public placeholder: string;

    /**
     * Updates the placeholder on the proxy element.
     * @internal
     */
    protected placeholderChanged(): void {
        if (this.proxy instanceof HTMLInputElement) {
            this.proxy.placeholder = this.placeholder;
        }
    }

    /**
     * The value property.
     *
     * @public
     */
    public get value() {
        Observable.track(this, "value");
        return this._value;
    }

    public set value(next: string) {
        const prev = `${this._value}`;

        if (this.$fastController.isConnected && this.options) {
            const selectedIndex = this.options.findIndex(
                el => el.text.toLowerCase() === next.toLowerCase()
            );

            const prevSelectedValue = this.options[this.selectedIndex]?.text;
            const nextSelectedValue = this.options[selectedIndex]?.text;

            this.selectedIndex =
                prevSelectedValue !== nextSelectedValue
                    ? selectedIndex
                    : this.selectedIndex;

            next = this.firstSelectedOption?.text || next;
        }

        if (prev !== next) {
            this._value = next;
            super.valueChanged(prev, next);
            Observable.notify(this, "value");
        }
    }

    /**
     * Cleanup function for the listbox positioner.
     *
     * @public
     */
    public cleanup: () => void;

    /**
     * Handle opening and closing the listbox when the combobox is clicked.
     *
     * @param e - the mouse event
     * @internal
     */
    public clickHandler(e: MouseEvent): boolean | void {
        const captured = (e.target as HTMLElement).closest(
            `option,[role=option]`
        ) as FASTListboxOption | null;

        if (this.disabled || captured?.disabled) {
            return;
        }

        if (this.open) {
            if (e.composedPath()[0] === this.control) {
                return;
            }

            if (captured) {
                this.selectedOptions = [captured];
                this.control.value = captured.text;
                this.clearSelectionRange();
                this.updateValue(true);
            }
        }

        this.open = !this.open;

        if (this.open) {
            this.control.focus();
        }

        return true;
    }

    public connectedCallback() {
        super.connectedCallback();
        if (this.value) {
            this.initialValue = this.value;
        }
    }

    /**
     * Synchronize the `aria-disabled` property when the `disabled` property changes.
     *
     * @param prev - The previous disabled value
     * @param next - The next disabled value
     *
     * @internal
     */
    public disabledChanged(prev: boolean, next: boolean): void {
        if (super.disabledChanged) {
            super.disabledChanged(prev, next);
        }
        this.ariaDisabled = this.disabled ? "true" : "false";
    }

    public disconnectedCallback(): void {
        this.cleanup?.();
        super.disconnectedCallback();
    }

    /**
     * Filter available options by text value.
     *
     * @public
     */
    public filterOptions(): void {
        if (!this.autocomplete || this.autocomplete === ComboboxAutocomplete.none) {
            this.filter = "";
        }

        const filter = this.filter.toLowerCase();

        this.filteredOptions = this._options.filter(o =>
            o.text.toLowerCase().startsWith(this.filter.toLowerCase())
        );

        if (this.isAutocompleteList) {
            if (!this.filteredOptions.length && !filter) {
                this.filteredOptions = this._options;
            }

            this._options.forEach(o => {
                o.hidden = !this.filteredOptions.includes(o);
            });
        }
    }

    /**
     * Focus the control and scroll the first selected option into view.
     *
     * @internal
     * @remarks
     * Overrides: `Listbox.focusAndScrollOptionIntoView`
     */
    protected focusAndScrollOptionIntoView(): void {
        if (this.contains(getRootActiveElement(this))) {
            this.control.focus();
            if (this.firstSelectedOption) {
                requestAnimationFrame(() => {
                    this.firstSelectedOption?.scrollIntoView({ block: "nearest" });
                });
            }
        }
    }

    /**
     * Handle focus state when the element or its children lose focus.
     *
     * @param e - The focus event
     * @internal
     */
    public focusoutHandler(e: FocusEvent): boolean | void {
        this.syncValue();

        if (!this.open) {
            return true;
        }

        const focusTarget = e.relatedTarget as HTMLElement;
        if (this.isSameNode(focusTarget)) {
            this.focus();
            return;
        }

        if (!this.options || !this.options.includes(focusTarget as FASTListboxOption)) {
            this.open = false;
        }
    }

    /**
     * Handle content changes on the control input.
     *
     * @param e - the input event
     * @internal
     */
    public inputHandler(e: InputEvent): boolean | void {
        this.filter = this.control.value;
        this.filterOptions();

        if (!this.isAutocompleteInline) {
            this.selectedIndex = this.options
                .map(option => option.text)
                .indexOf(this.control.value);
        }

        if (e.inputType.includes("deleteContent") || !this.filter.length) {
            return true;
        }

        if (this.isAutocompleteList && !this.open) {
            this.open = true;
        }

        if (this.isAutocompleteInline) {
            if (this.filteredOptions.length) {
                this.selectedOptions = [this.filteredOptions[0]];
                this.selectedIndex = this.options.indexOf(this.firstSelectedOption);
                this.setInlineSelection();
            } else {
                this.selectedIndex = -1;
            }
        }
        return;
    }

    /**
     * Handle keydown actions for listbox navigation.
     *
     * @param e - the keyboard event
     * @internal
     */
    public keydownHandler(e: Event & KeyboardEvent): boolean | void {
        const key = e.key;

        if (e.ctrlKey || e.shiftKey) {
            return true;
        }

        switch (key) {
            case "Enter": {
                this.syncValue();
                if (this.isAutocompleteInline) {
                    this.filter = this.value;
                }

                this.open = false;
                this.clearSelectionRange();
                break;
            }

            case "Escape": {
                if (!this.isAutocompleteInline) {
                    this.selectedIndex = -1;
                }

                if (this.open) {
                    this.open = false;
                    break;
                }

                this.value = "";
                this.control.value = "";
                this.filter = "";
                this.filterOptions();
                break;
            }

            case "Tab": {
                this.setInputToSelection();

                if (!this.open) {
                    return true;
                }

                e.preventDefault();
                this.open = false;
                break;
            }

            case "ArrowUp":
            case "ArrowDown": {
                this.filterOptions();

                if (!this.open) {
                    this.open = true;
                    break;
                }

                if (this.filteredOptions.length > 0) {
                    super.keydownHandler(e);
                }

                if (this.isAutocompleteInline) {
                    this.setInlineSelection();
                }

                break;
            }

            default: {
                return true;
            }
        }
    }

    /**
     * Handle keyup actions for value input and text field manipulations.
     *
     * @param e - the keyboard event
     * @internal
     */
    public keyupHandler(e: KeyboardEvent): boolean | void {
        const key = e.key;

        switch (key) {
            case "ArrowLeft":
            case "ArrowRight":
            case "Backspace":
            case "Delete":
            case "Home":
            case "End": {
                this.filter = this.control.value;
                this.selectedIndex = -1;
                this.filterOptions();
                break;
            }
        }
    }

    /**
     * Ensure that the selectedIndex is within the current allowable filtered range.
     *
     * @param prev - the previous selected index value
     * @param next - the current selected index value
     *
     * @internal
     */
    public selectedIndexChanged(prev: number | undefined, next: number): void {
        if (this.$fastController.isConnected) {
            next = limit(-1, this.options.length - 1, next);

            // we only want to call the super method when the selectedIndex is in range
            if (next !== this.selectedIndex) {
                this.selectedIndex = next;
                return;
            }

            super.selectedIndexChanged(prev, next);
        }
    }

    /**
     * Move focus to the previous selectable option.
     *
     * @internal
     * @remarks
     * Overrides `Listbox.selectPreviousOption`
     */
    public selectPreviousOption(): void {
        if (!this.disabled && this.selectedIndex >= 0) {
            this.selectedIndex = this.selectedIndex - 1;
        }
    }

    /**
     * Set the default selected options at initialization or reset.
     *
     * @internal
     * @remarks
     * Overrides `Listbox.setDefaultSelectedOption`
     */
    public setDefaultSelectedOption(): void {
        if (this.$fastController.isConnected && this.options) {
            const selectedIndex = this.options.findIndex(
                el => el.getAttribute("selected") !== null || el.selected
            );

            this.selectedIndex = selectedIndex;
            if (!this.dirtyValue && this.firstSelectedOption) {
                this.value = this.firstSelectedOption.text;
            } else {
                this.value = "";
            }
            this.setSelectedOptions();
        }
    }

    /**
     * Focus and set the content of the control based on the first selected option.
     *
     * @internal
     */
    private setInputToSelection(): void {
        if (this.firstSelectedOption) {
            this.control.value = this.firstSelectedOption.text;
            this.control.focus();
        }
    }

    /**
     * Focus, set and select the content of the control based on the first selected option.
     *
     * @internal
     */
    private setInlineSelection(): void {
        if (this.firstSelectedOption) {
            this.setInputToSelection();
            this.control.setSelectionRange(
                this.filter.length,
                this.control.value.length,
                "backward"
            );
        }
    }

    /**
     * Determines if a value update should involve emitting a change event, then updates the value.
     *
     * @internal
     */
    private syncValue(): void {
        const newValue =
            this.selectedIndex > -1 ? this.firstSelectedOption?.text : this.control.value;
        this.updateValue(this.value !== newValue);
    }

    /**
     * Calculate and apply listbox positioning based on available viewport space.
     *
     * @public
     */
    public setPositioning(): void {
        if (this.$fastController.isConnected) {
            this.cleanup = autoUpdate(this, this.listbox, async () => {
                const { middlewareData, x, y } = await computePosition(
                    this,
                    this.listbox,
                    {
                        placement: "bottom",
                        strategy: "fixed",
                        middleware: [
                            flip(),
                            size({
                                apply: ({ availableHeight, rects }) => {
                                    Object.assign(this.listbox.style, {
                                        maxHeight: `${availableHeight}px`,
                                        width: `${rects.reference.width}px`,
                                    });
                                },
                            }),
                            hide(),
                        ],
                    }
                );

                if (middlewareData.hide?.referenceHidden) {
                    this.open = false;
                    this.cleanup();
                    return;
                }

                Object.assign(this.listbox.style, {
                    position: "fixed",
                    top: "0",
                    left: "0",
                    transform: `translate(${x}px, ${y}px)`,
                });
            });
        }
    }

    /**
     * Ensure that the entire list of options is used when setting the selected property.
     *
     * @param prev - the previous list of selected options
     * @param next - the current list of selected options
     *
     * @internal
     * @remarks
     * Overrides: `Listbox.selectedOptionsChanged`
     */
    public selectedOptionsChanged(
        prev: FASTListboxOption[] | undefined,
        next: FASTListboxOption[]
    ): void {
        if (this.$fastController.isConnected) {
            this._options.forEach(o => {
                o.selected = next.includes(o);
            });
        }
    }

    /**
     * Synchronize the form-associated proxy and update the value property of the element.
     *
     * @param prev - the previous collection of slotted option elements
     * @param next - the next collection of slotted option elements
     *
     * @internal
     */
    public slottedOptionsChanged(prev: Element[] | undefined, next: Element[]): void {
        super.slottedOptionsChanged(prev, next);
        this.updateValue();
    }

    /**
     * Sets the value and to match the first selected option.
     *
     * @param shouldEmit - if true, the change event will be emitted
     *
     * @internal
     */
    private updateValue(shouldEmit?: boolean) {
        if (this.$fastController.isConnected) {
            this.value = this.firstSelectedOption?.text || this.control.value;
            this.control.value = this.value;
        }

        if (shouldEmit) {
            this.$emit("change");
        }
    }

    /**
     * @internal
     */
    private clearSelectionRange() {
        const controlValueLength = this.control.value.length;
        this.control.setSelectionRange(controlValueLength, controlValueLength);
    }
}

/**
 * Includes ARIA states and properties relating to the ARIA combobox role.
 *
 * @public
 */
export class DelegatesARIACombobox {
    /**
     * See {@link https://www.w3.org/TR/wai-aria-1.2/#aria-autocomplete} for more information.
     *
     * @public
     * @remarks
     * HTML Attribute: `aria-autocomplete`
     */
    @observable
    public ariaAutoComplete: "inline" | "list" | "both" | "none" | string | null;

    /**
     * See {@link https://www.w3.org/TR/wai-aria-1.2/#aria-controls} for more information.
     *
     * @public
     * @remarks
     * HTML Attribute: `aria-controls`
     */
    @observable
    public ariaControls: string | null;
}

/**
 * Mark internal because exporting class and interface of the same name
 * confuses API documenter.
 * TODO: https://github.com/microsoft/fast/issues/3317
 * @internal
 */
export interface DelegatesARIACombobox extends DelegatesARIAListbox {}
applyMixins(DelegatesARIACombobox, DelegatesARIAListbox);

/**
 * Mark internal because exporting class and interface of the same name
 * confuses API documenter.
 * TODO: https://github.com/microsoft/fast/issues/3317
 * @internal
 */
export interface FASTCombobox extends StartEnd, DelegatesARIACombobox {}
applyMixins(FASTCombobox, StartEnd, DelegatesARIACombobox);